googleapis / googleapis/google-cloud-go

auth/credentials/idtoken: NewCredentials sends unauthenticated GenerateIdToken requests (401 CREDENTIALS_MISSING) for external_account / impersonated_service_account credentials

Open
#19,939 1 comment 0 reactions 0 assignees View on GitHub
priority: p2 type: bug
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.