googleapis / googleapis/google-cloud-go
auth/credentials/idtoken: NewCredentials sends unauthenticated GenerateIdToken requests (401 CREDENTIALS_MISSING) for external_account / impersonated_service_account credentials
- Dominant language
- Go
- Stars
- 4.5k
- Forks
- 1.6k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 109
Description
## Client
`cloud.google.com/go/auth/credentials/idtoken`
## Environment
- linux/amd64 on GitHub Actions (also reproducible on macOS darwin/arm64)
- `$ go version`: go1.26.4
- `cloud.google.com/go/auth` v0.20.0 (latest release; the affected code is unchanged on `main` as of 2026-06-10)
- ADC is an `external_account` (Workload Identity Federation) credential config with `service_account_impersonation_url`, generated by `google-github-actions/auth` with `workload_identity_provider` + `service_account`
- An `impersonated_service_account` ADC (e.g. `gcloud auth application-default login --impersonate-service-account=...`) takes the same code path
## Code and Dependencies
```go
package main
import (
"context"
"log"
"cloud.google.com/go/auth/credentials/idtoken"
)
func main() {
// GOOGLE_APPLICATION_CREDENTIALS points to an external_account credential config with service_account_impersonation_url.
creds, err := idtoken.NewCredentials(&idtoken.Options{
Audience: "https://my-service.a.run.app",
})
if err != nil {
log.Fatal(err)
}
if _, err := creds.Token(context.Background()); err != nil {
log.Fatal(err)
}
}
```
go.mod
```text
module repro
go 1.26.0
require cloud.google.com/go/auth v0.20.0
```
## Expected behavior
`creds.Token` returns an ID token for the service account referenced by `service_account_impersonation_url`. The underlying `iamcredentials.generateIdToken` call is authenticated with the base credentials.
## Actual behavior
The `generateIdToken` request is sent **without any Authorization header** and the IAM Credentials API rejects it:
```
failed to create Token: impersonate: status code 401: {
"error": {
"code": 401,
"message": "Request is missing required authentication credential. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.",
"status": "UNAUTHENTICATED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "CREDENTIALS_MISSING",
"domain": "googleapis.com",
"metadata": {
"method": "google.iam.credentials.v1.IAMCredentials.GenerateIdToken",
"service": "iamcredentials.googleapis.com"
}
}
]
}
}
```
Note this is a 401 (no credentials at all), not a 403 (permission denied), so it is unrelated to IAM permissions on the target service account.
## Additional context
### Root cause
`credsFromDefault` in `idtoken/file.go` always passes `Client: opts.client()` to `impersonate.IDTokenOptions` for the `ImpersonatedServiceAccount` / `ExternalAccount` case:
https://github.com/googleapis/google-cloud-go/blob/auth/v0.20.0/auth/credentials/idtoken/file.go#L85-L92
`Options.client()` returns `internal.DefaultClient()` when the caller did not set `Options.Client` — a plain `*http.Client` with no authentication:
https://github.com/googleapis/google-cloud-go/blob/auth/v0.20.0/auth/credentials/idtoken/idtoken.go#L153-L158
`impersonate.NewIDTokenCredentials` only builds an authenticated client from `Credentials` when `opts.Client == nil`. Since `file.go` always passes a non-nil client, that branch is skipped and the plain client is used as-is for the `GenerateIdToken` call; `opts.Credentials` is only consulted for universe-domain resolution:
https://github.com/googleapis/google-cloud-go/blob/auth/v0.20.0/auth/credentials/impersonate/idtoken.go#L94-L126
This violates the documented contract of `impersonate.IDTokenOptions.Client` itself: "If provided this should be a fully-authenticated client."
https://github.com/googleapis/google-cloud-go/blob/auth/v0.20.0/auth/credentials/impersonate/idtoken.go#L52-L55
### Relationship to #11105 / #14474
#11105 reported a different failure mode (403 due to double impersonation) on the same code path, and #14474 fixed it by introducing `baseCredsForImpersonation`. However, the fix keeps `Client: opts.client()` in `file.go`, and `impersonate.NewIDTokenCredentials` on `main` still ignores `Credentials` for HTTP authentication whenever `Client` is non-nil. So the carefully de-impersonated base credentials introduced by #14474 are never used to authenticate the `GenerateIdToken` request, and the 401 reproduces on `main` as well.
### Suggested fix
In `credsFromDefault`, pass the user-provided client through as-is (possibly nil) instead of materializing a default one.
Before:
```go
config := impersonate.IDTokenOptions{
Audience: opts.Audience,
TargetPrincipal: account,
IncludeEmail: true,
Client: opts.client(), // always non-nil: falls back to the unauthenticated internal.DefaultClient()
Credentials: baseCreds,
Logger: internallog.New(opts.Logger),
}
```
After:
```go
config := impersonate.IDTokenOptions{
Audience: opts.Audience,
TargetPrincipal: account,
IncludeEmail: true,
Client: opts.Client, // nil unless explicitly provided by the user
Credentials: baseCreds,
Logger: internallog.New(opts.Logger),
}
```
With `Client == nil`, `impersonate.NewIDTokenCredentials` builds an authenticated client from `baseCreds`, which is exactly the intended flow. Alternatively, `impersonate.NewIDTokenCredentials` could wrap a provided plain client with authentication when `Credentials` is also given, but that would change the documented semantics of `IDTokenOptions.Client`.
### Workaround
Bypass `idtoken.NewCredentials` and call `impersonate.NewIDTokenCredentials` directly with both `Client` and `Credentials` unset, so the library detects ADC and builds an authenticated client itself:
```go
creds, err := impersonate.NewIDTokenCredentials(&impersonate.IDTokenOptions{
Audience: audience,
TargetPrincipal: serviceAccountEmail,
IncludeEmail: true,
})
```
Contributor guide
Assessment
This issue has not been assessed yet.