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 thepat: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, whichinitrejected outright. It now works. ThePasswordCredentialsAuthSourcebranch stays a type case, because it carries no token and needs a username from the API, so it cannot come fromCredential.
Behaviour changes, deliberately
opentofu initaccepts 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.cluster graphon a host with no credentials at all now saysglab is not authenticated; run 'glab auth login' to authenticaterather 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-generatedTestInit_CommandConstruction_OAuth2AccessTokenOnly fails without the init.go change,
so it pins the fix rather than describing it.