fix(coderd/externalauth): preserve scopes on token refresh - #24851
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
6aad158 to
3e81671
Compare
|
I have read the CLA Document and I hereby sign the CLA |
|
recheck |
External-auth providers backed by Microsoft Entra ID's v1 token issuer silently narrow refreshed access tokens to the user's default consent set, dropping any custom resource scopes the original token carried. Symptom: external-auth-backed integrations work for ~1 hour after a fresh sign-in, then fail silently after the first refresh — the token is signature-valid, but resource servers reject it because the scope they need is gone. Hit this with an Entra ID external auth provider whose scopes include `api://<app-id>/session:role-any` for Snowflake External OAuth: every refresh produced a token containing only User.Read, and Snowflake returned "requested role is not listed in the access token or was filtered". Root cause: golang.org/x/oauth2's TokenSource refresh path does not echo the original `scope` parameter on the refresh request, and Entra v1 treats the absence of `scope` as "fall back to default consent set". Mirrors the fix in stacklok/toolhive#5096, which patched the same upstream library bug for the same reason: replace TokenSource(...).Token() with a direct Exchange call carrying explicit grant_type/refresh_token/scope params. Also preserve the original refresh_token on the response when the authorization server omits a new one (per RFC 6749 §6). - Add Config.Scopes mirroring oauth2.Config.Scopes; populate from ExternalAuthConfig.Scopes in ConvertConfig. - Replicate TokenSource's not-expired-yet short-circuit in the new refresh path so we don't hit the IdP unnecessarily.
3e81671 to
741f623
Compare
|
Rebased onto current The merge is clean and the scope-preservation fix is intact: the retry loop still refreshes via Verified locally on the rebased branch:
This was flagged |
|
@coder/community-triage — this is a small (2-file) community fix that's been sitting without review and got auto-labeled Summary: Entra v1 silently narrows refreshed access tokens because |
This comment has been minimized.
This comment has been minimized.
|
Thank you for the PR! I will take a look soon, in the meantime I let CI run. |
code-asher
left a comment
There was a problem hiding this comment.
Seeing some CI issues but the fix makes sense to me. Kinda feels like a bug with Entra but what can ya do.
…raV1Oauth.TokenSource Address review feedback: instead of a generic (*Config).refreshTokenOnce helper backed by a new Config.Scopes field, override TokenSource on entraV1Oauth so the scope-preserving refresh applies only to the provider that needs it and stays transparent to callers. The custom token source wraps oauth2.ReuseTokenSource (preserving the not-expired short-circuit) and, on refresh, does a direct Exchange with explicit grant_type/refresh_token/scope params, reading scopes from the embedded oauth2.Config.Scopes. refreshTokenWithRetry is unchanged from main and keeps calling c.TokenSource(...).Token().
code-asher
left a comment
There was a problem hiding this comment.
Awesome thank you! I threw on some comments about why we need the override, will merge once CI passes.
Problem
External-auth providers backed by Microsoft Entra ID's v1 token issuer silently narrow refreshed access tokens to the user's default consent set, dropping any custom resource scopes the original token carried. Externally, this looks like external-auth-backed integrations working fine for ~1 hour after sign-in, then failing silently after the first refresh — the refreshed token has a valid signature, but resource servers reject it because the scope they require is gone.
We hit this with an Entra ID external auth provider whose scopes include
api://<app-id>/session:role-anyfor Snowflake External OAuth. Every refresh returned a token containing onlyUser.Read, and Snowflake responded withrequested role is not listed in the access token or was filtered.Root cause
golang.org/x/oauth2'sTokenSourcerefresh path does not echo the originalscopeparameter on the refresh request, and Entra v1 treats the absence ofscopeas "fall back to the user's default consent set". Known upstream library limitation — see golang/oauth2#761.Fix
Override
TokenSourceonentraV1Oauth— the wrapper already used for Azure DevOps + Entra v1 providers. The scope-preserving refresh now lives only on the provider that needs it and is transparent to callers:refreshTokenWithRetrystill callsc.TokenSource(...).Token()exactly as onmain, and for Entra v1 providers that resolves to the new token source.The custom token source wraps
oauth2.ReuseTokenSource(so the not-expired short-circuit is preserved) and, on refresh, performs a directExchangecarrying explicitgrant_type=refresh_token+refresh_token=<token>+ (when the provider has scopes)scope=<scopes>form parameters. Scopes are read from the embeddedoauth2.Config.Scopes, so no new config plumbing is needed. Because the refresh dispatches throughentraV1Oauth.Exchange, theresource=<app-id>parameter required for v1 tokens is still added.Also preserve the original refresh_token on the response when the authorization server omits a new one (per RFC 6749 §6, the AS MAY return a new refresh token and SHOULD treat the existing one as still valid otherwise).
Changes:
entraV1Oauth.TokenSourceplus anentraV1TokenSourcerefresher; scopes come from the embedded*oauth2.Config.Scopes.Config,ConvertConfig, orrefreshTokenWithRetrybeyond what already exists onmain— the earlier revision'sConfig.Scopesfield andrefreshTokenOncehelper are gone.Behaviour for other providers
Non-Entra providers are unaffected:
TokenSourceis overridden only onentraV1Oauth. Every other provider continues to use the stockgolang.org/x/oauth2token source, so their refresh requests are unchanged. Refresh requests also remain instrumented undersource="TokenSource"in thecoderd_oauth2_external_requests_totalmetric, since the refresh still flows throughTokenSource.Test plan
go build ./coderd/externalauth/coderd/externalauthpass locally (the DB-backedTestRefreshToken/*subtests require Postgres).session:role-anyscope and Snowflake rejected withrequested role is not listed in the access token or was filtered. Post-patch:scpclaim on refreshed tokens still includessession:role-anyafter >1h, Snowflake External OAuth refresh succeeds, and GitHub validation behavior (via theValidateTokenstep) is unchanged.New unit tests in
coderd/externalauth/externalauth_test.gobuild an Entra v1 provider viaConvertConfigand exercise the refresh:TestRefreshTokenWithScopes/EchoesConfiguredScopesOnRefresh— verifies thescopeparam echoes the configured scopes joined by space.TestRefreshTokenWithScopes/OmitsScopeParamWhenScopesEmpty— verifies noscopeparam is sent when the provider has no scopes.TestRefreshTokenWithScopes/PreservesPriorRefreshTokenWhenASOmitsNewOne— RFC 6749 §6 behavior.TestRefreshTokenWithScopes/AcceptsRotatedRefreshTokenWhenASReturnsOne— refresh-token rotation still flows through.AI assistance disclosure
Per AI_CONTRIBUTING.md: this PR was written with AI assistance. I (the author) am personally accountable for the change, reviewed every line, and verified it manually — see the Test plan above, including the ~5-day production deployment against a live Entra ID → Snowflake External OAuth provider where the
scpclaim survives refresh post-patch.