fix(auth): support access-token-only OAuth2 in credential helpers

What does this MR do?

Fixes glab auth credential-helper returning {"type":"error","message":"unable to determine token"} for a host authenticated with an OAuth2 access token that has no refresh token, which breaks glab duo cli.

Closes #8515 (closed).

Note

On the glab duo cli symptom specifically: the helper now returns a valid credential, and duo cli accepts it when an expiry date is on record for the host. That is the normal case, since glab auth login writes oauth2_expiry_date on every OAuth login. Where no usable date is stored, the response carries no expiry_timestamp and duo cli still rejects it, because its schema requires that field for an oauth2 credential. Raised as gitlab-lsp#2955; out of reach from here. Thanks to @jay_mccure for testing the path end to end rather than taking the unit tests' word for it.

The bug

e53cde381 added oauth2AccessTokenOnlyAuthSource for third-party tools that own their own OAuth2 access token, and wired it into the request path. The consumers that extract a raw token were not updated. Two of them break:

Command Symptom
glab auth credential-helper unable to determine token — breaks glab duo cli
glab auth git-credential Expected OAuthTokenSource auth source for "gitlab.com", got api.oauth2AccessTokenOnlyAuthSource — breaks HTTPS git push/pull

The reported case reached that state a second way: switching from OAuth to a personal access token without logging out, leaving is_oauth2: true with no refresh token. GitLab accepts a PAT sent as Authorization: Bearer, so glab itself keeps working and glab auth status shows all green — only the helpers fail, far from the cause.

Reproduced before the fix with a config of is_oauth2: "true" plus a token and no oauth2_refresh_token; both commands now succeed.

Changes

1. Both credential helpers resolve through api.Client.Credential. !3805 (merged) merged after this MR opened and added Credential as the single place that maps an auth source onto a credential kind. credential-helper and git-credential now ask the client for a resolved credential instead of type-switching on the auth source. This is #8523's first item, brought forward because it is also what resolves the merge conflict below.

2. glab config set token --host X clears the OAuth fields when the host is OAuth-authenticated, and says so on stderr. glab auth login --token already did this; config set wrote straight through, which is the remaining way to create the inconsistent state.

3. glab auth status warns on is_oauth2: true with no refresh token. Skipped when is_oauth2 comes from the environment (that is the intended third-party mode from e53cde381, where no refresh token is expected) and when no token is stored (the existing messages already cover it).

4. Credential carries the stored expiry for an access-token-only session. marshal writes oauth2_expiry_date on every OAuth login, but NewClientFromConfig dropped it when building the access-token-only source, so both helpers reported a credential with no expiry at all. It now reaches Credential.Expiry, credential-helper reports expiry_timestamp, and git-credential reports password_expiry_utc. An absent or unparseable date still yields no expiry — there is no refresh token to derive one from, and inventing one would be worse. The RFC822 fallback for dates written by older versions is oauth2.ParseExpiryDate rather than a second copy.

5. The five-minute token grace period is removed rather than carried into Credential. #8522 records that it never applied; the mechanism is ReuseTokenSourceWithExpiry(nil, src, grace), which caches nothing, so the first Token() call delegates straight to src and the early-expiry window is only consulted on a later call that a one-shot credential helper never makes. Porting it into shared code would have implied a guarantee that was not there. #8522 stays open for the real fix, which needs the expiry delta to reach the token source itself.

Rebased onto !3805 (merged)

!3805 (merged) merged on 2026-09-01 and its credential.go refers to oauth2AccessTokenOnlyAuthSource by its unexported name. This branch had exported that type so the helpers could match on it. Git merges the two without reporting a conflict and produces a tree that does not compile:

internal/api/credential.go:69:7: undefined: oauth2AccessTokenOnlyAuthSource
internal/api/credential.go:86:32: undefined: oauth2AccessTokenOnlyAuthSource

The green pipeline on this MR predated that merge, so neither the conflict check nor CI caught it. Going through Credential means neither helper needs the concrete type, so it is unexported again and the collision is gone.

Two things fall out of the conversion:

  • tokenFromHeader is deleted. Recovering the credential kind by matching a header name could never distinguish OAuthTokenSource from the access-token-only source, since both emit Authorization: Bearer — which is exactly the distinction item 4 depends on. This closes the thread asking whether that inference was temporary: it was, and it is gone.
  • The recover() guard around Header is deleted. Credential never calls Header, so an auth source that leaves an embedded interface nil until Init returns ErrUnsupportedAuthSource instead of panicking. The regression test stays, driving the real command with *gitlab.PasswordCredentialsAuthSource.

Not included

login/helper.go still keys its outer branch on the stored is_oauth2 rather than on the resolved credential. That is #8523's second item and the OAUTH_TOKEN misclassification it describes; it interacts with !3794, which is still open.

opentofu init and cluster graph were the other two consumers; !3805 (merged) already converted both.

Screenshots or screen recordings

Against a config with is_oauth2: "true", a token, and a stored oauth2_expiry_date:

$ glab auth credential-helper
{"type":"success","instance_url":"https://gitlab.com","token":{"type":"oauth2","token":"glpat-...","expiry_timestamp":"2026-09-30T12:00:00Z"}}

$ printf 'protocol=https\nhost=gitlab.com\n\n' | glab auth git-credential get
capability[]=authtype
username=oauth2
password=glpat-...
password_expiry_utc=1790766000

$ glab auth status
  ! gitlab.com is configured for OAuth, but no refresh token is stored, so the
    token cannot be renewed when it expires. This is left behind by switching to
    a personal access token without logging out.
    To clear it, run glab auth login --hostname gitlab.com.

$ glab config set token glpat-new --host gitlab.com
! Cleared the OAuth configuration for gitlab.com, which authenticates with the token you set.

With no oauth2_expiry_date stored, the two expiry fields are absent and everything else is unchanged.

How to set up and validate locally

mkdir -p /tmp/glabstale && cat > /tmp/glabstale/config.yml <<'YAML'
hosts:
    gitlab.com:
        api_protocol: https
        api_host: gitlab.com
        is_oauth2: "true"
        oauth2_expiry_date: "2026-09-30T12:00:00Z"
        token: <a real PAT>
YAML
chmod 600 /tmp/glabstale/config.yml
GLAB_CONFIG_DIR=/tmp/glabstale ./bin/glab auth credential-helper

On main this returns unable to determine token; on this branch it returns the token with "type":"oauth2" and the expiry. Drop the oauth2_expiry_date line to see the no-expiry response.

MR acceptance checklist

  • Tests added for the new behaviour, including the negative branches (unsupported auth source, non-OAuth host, refresh token present, no token stored, env-provided is_oauth2, absent and unparseable expiry date, an auth source that is uninitialised).
  • The access-token-only tests build their client through api.NewClientFromConfig rather than injecting an auth source, so they cover the real construction path including the expiry.
  • The two tests where a zero expiry is dropped from the JSON assert the serialised response, since the struct cannot distinguish an omitted field from a zero timestamp.
  • make lint clean, full suite passes (4766 tests).
  • make gen-docs produces no drift — the touched commands are hidden or their help text is unchanged.
Edited by Kai Armstrong

Merge request reports

Loading
Loading