refactor(api): add Client.Credential as the auth source seam

What does this MR do and why?

Five places consume Client.AuthSource(). Two ask it for a header and are fine. The other three need to know what kind of credential they hold, and each type-switches to work it out, with its own failure mode when the switch has no matching case:

Consumer How it reads the auth source Handles an access-token-only OAuth2 source?
internal/commands/orbit/credential.go Header() yes, generic by construction
internal/commands/auth/credentialhelper type switch no — unable to determine token (#8515 (closed))
internal/commands/auth/login/helper.go type switch no
internal/commands/opentofu/init type switch no — does not support this authentication method
internal/commands/cluster/graph assertion to AccessTokenAuthSource no, deliberately

The duplication is not only repetition. Each of those switches re-decides something NewClientFromConfig already decided from GLAB_IS_OAUTH2, the variable that supplied the token, and the stored is_oauth2 flag. A consumer that re-derives it from configuration is answering a different question than "what did the client actually do", and they drift.

What changed

Client.Credential(ctx) does the mapping once, in the package that owns the auth source types, and returns the kind alongside the token, expiry, and refresh token. Callers that only need to set a header keep using AuthSource().Header().

Two consumers converted — the two that no other open change touches, so this can be reviewed and merged independently:

  • cluster graph: the token-kind check becomes a check on the kind rather than a type assertion that doubles as one. This names the constraint it was silently enforcing: KAS accepts only the pat: authorization scheme, so only a personal or project access token works. Adding the first test file to that package pins the refusal.
  • opentofu init: an OAuth2 host with no stored refresh token resolves to an access-token-only auth source, which init rejected outright. It now works. The PasswordCredentialsAuthSource branch stays a type case, because it carries no token and needs a username from the API, so it cannot come from Credential.

Behaviour changes, deliberately

  1. opentofu init accepts an access-token-only OAuth2 credential instead of erroring. This is the fix; the header it emits is byte-identical to the one it already emitted for a personal access token.
  2. cluster graph on a host with no credentials at all now says glab is not authenticated; run 'glab auth login' to authenticate rather than the token-kind message. Propagating that error is both simpler and more actionable than folding it into "supports only personal and project access tokens".

Everything else is behaviour-preserving, including the OAuth2 refresh path — see the note below.

Question for review

cluster graph refuses a genuine OAuth token, which is correct — KAS would reject it — but the message reads as though the command is limited rather than the token. I kept the wording unchanged here rather than decide it. Worth a follow-up if it should name the scheme.

A note on refresh grace periods

An earlier revision had Credential renew an OAuth2 token five minutes before expiry, copying credentialhelper's tokenGracePeriod. Review prompted a test, and the test failed: the grace period never applied.

ReuseTokenSourceWithExpiry(nil, src, d) reconfigures src in place when src is already a *reuseTokenSource, but reuseTokenSource.Token decides validity from s.t.Valid() — the cached token's own expiryDelta, stamped on when that token was cached. The source's delta is only applied to tokens fetched afterwards (oauth2.go:318). So the token actually returned stays governed by the 10-second default and the wider window is inert.

The grace period is dropped here, which leaves the OAuth2 path behaving exactly as opentofu init did before. Worth knowing that credentialhelper.go:127 has the identical pattern, so glab auth credential-helper can hand Git a token expiring in seconds despite intending a five-minute buffer — pre-existing, out of scope here, and filed separately.

Sequencing

Independent of !3798 (merged) and !3794. Neither touches graph.go or init.go, and Credential lives in internal/api alongside oauth2AccessTokenOnlyAuthSource, so it needs no exported type and creates no textual conflict.

One coupling to expect: !3798 (merged) renames that type and its field, and this MR references the unexported names in credential.go and credential_test.go. Whichever merges second needs a three-line fixup. Git will not report a conflict, but the merge-result pipeline fails the build, so it surfaces loudly.

Converting credentialhelper and auth/login/helper.go is the follow-up, and has to wait for those two. It should also delete tokenFromHeader from !3798 (merged), which recovers the kind by matching the header name and stripping Bearer — information internal/api had and threw away. Note that a header cannot serve git-credential: OAuthTokenSource and oauth2AccessTokenOnlyAuthSource both emit Authorization: Bearer, but only the first has an expiry and refresh token to report to Git.

Verification

make lint              # 0 issues
make test-changed      # DONE 3596 tests, 8 skipped
lefthook run pre-push  # all green, including check-generated

TestInit_CommandConstruction_OAuth2AccessTokenOnly fails without the init.go change, so it pins the fix rather than describing it.

Edited by Kai Armstrong

Merge request reports

Loading
Loading