Skip to content

fix(coderd/externalauth): preserve scopes on token refresh - #24851

Merged
code-asher merged 5 commits into
coder:mainfrom
dallinstevens:fix/external-auth-refresh-scope-narrowing
Jul 9, 2026
Merged

fix(coderd/externalauth): preserve scopes on token refresh#24851
code-asher merged 5 commits into
coder:mainfrom
dallinstevens:fix/external-auth-refresh-scope-narrowing

Conversation

@dallinstevens

@dallinstevens dallinstevens commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

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-any for Snowflake External OAuth. Every refresh returned a token containing only User.Read, and Snowflake responded with 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 the user's default consent set". Known upstream library limitation — see golang/oauth2#761.

Fix

Override TokenSource on entraV1Oauth — 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: refreshTokenWithRetry still calls c.TokenSource(...).Token() exactly as on main, 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 direct Exchange carrying explicit grant_type=refresh_token + refresh_token=<token> + (when the provider has scopes) scope=<scopes> form parameters. Scopes are read from the embedded oauth2.Config.Scopes, so no new config plumbing is needed. Because the refresh dispatches through entraV1Oauth.Exchange, the resource=<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:

  • New entraV1Oauth.TokenSource plus an entraV1TokenSource refresher; scopes come from the embedded *oauth2.Config.Scopes.
  • No changes to Config, ConvertConfig, or refreshTokenWithRetry beyond what already exists on main — the earlier revision's Config.Scopes field and refreshTokenOnce helper are gone.

Behaviour for other providers

Non-Entra providers are unaffected: TokenSource is overridden only on entraV1Oauth. Every other provider continues to use the stock golang.org/x/oauth2 token source, so their refresh requests are unchanged. Refresh requests also remain instrumented under source="TokenSource" in the coderd_oauth2_external_requests_total metric, since the refresh still flows through TokenSource.

Test plan

  • go build ./coderd/externalauth/
  • Non-DB unit tests in coderd/externalauth pass locally (the DB-backed TestRefreshToken/* subtests require Postgres).
  • Production-validated: deployed in a downstream fork for ~5 days against an Entra ID external-auth provider used to obtain Snowflake External OAuth tokens. Pre-patch: refreshed tokens lost the session:role-any scope and Snowflake rejected with requested role is not listed in the access token or was filtered. Post-patch: scp claim on refreshed tokens still includes session:role-any after >1h, Snowflake External OAuth refresh succeeds, and GitHub validation behavior (via the ValidateToken step) is unchanged.

New unit tests in coderd/externalauth/externalauth_test.go build an Entra v1 provider via ConvertConfig and exercise the refresh:

  • TestRefreshTokenWithScopes/EchoesConfiguredScopesOnRefresh — verifies the scope param echoes the configured scopes joined by space.
  • TestRefreshTokenWithScopes/OmitsScopeParamWhenScopesEmpty — verifies no scope param 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 scp claim survives refresh post-patch.

@github-actions github-actions Bot added the community Pull Requests and issues created by the community. label Apr 30, 2026
@github-actions

github-actions Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@dallinstevens
dallinstevens marked this pull request as ready for review April 30, 2026 20:41
@dallinstevens
dallinstevens marked this pull request as draft April 30, 2026 20:45
@github-actions github-actions Bot added the stale This issue is like stale bread. label May 28, 2026
@dallinstevens
dallinstevens marked this pull request as ready for review June 1, 2026 19:12
@dallinstevens
dallinstevens force-pushed the fix/external-auth-refresh-scope-narrowing branch 2 times, most recently from 6aad158 to 3e81671 Compare June 1, 2026 20:35
@dallinstevens

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

cdrci2 added a commit to coder/cla that referenced this pull request Jun 1, 2026
@dallinstevens dallinstevens changed the title fix(externalauth): preserve scopes on token refresh (Entra v1 narrowing) fix(coderd/externalauth): preserve scopes on token refresh (Entra v1 narrowing) Jun 1, 2026
@dallinstevens

Copy link
Copy Markdown
Contributor Author

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.
@dallinstevens
dallinstevens force-pushed the fix/external-auth-refresh-scope-narrowing branch from 3e81671 to 741f623 Compare June 22, 2026 05:43
@dallinstevens

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (was ~335 commits behind) and reconciled with #26231, which reworked the same refreshTokenWithRetry retry logic this PR builds on.

The merge is clean and the scope-preservation fix is intact: the retry loop still refreshes via refreshTokenOnce (the direct Exchange that echoes scope), so refreshed Entra v1 tokens keep their custom resource scopes. #26231's new "negative RefreshRetryTimeout disables retries" branch is a test-only path (the field is only set from tests) and doesn't affect production scope behavior.

Verified locally on the rebased branch:

  • go build ./coderd/externalauth/
  • All non-DB unit tests pass, including the four new TestRefreshTokenWithScopes/* cases (scope echo, scope omission, refresh-token preservation, rotation).

This was flagged stale purely from lack of review within the 7-day window, not from anything in the code. Would appreciate a maintainer look whenever there's bandwidth. Thanks!

@dallinstevens dallinstevens changed the title fix(coderd/externalauth): preserve scopes on token refresh (Entra v1 narrowing) fix(coderd/externalauth): preserve scopes on token refresh Jun 22, 2026
@dallinstevens

dallinstevens commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

@coder/community-triage — this is a small (2-file) community fix that's been sitting without review and got auto-labeled stale. It's now rebased onto current main. Could a maintainer take a look when there's bandwidth? cc @f0ssel @code-asher, since you've owned the externalauth refresh logic recently.

Summary: Entra v1 silently narrows refreshed access tokens because golang.org/x/oauth2's TokenSource doesn't echo scope on refresh. The fix refreshes via a direct Exchange that carries the original scopes; production-validated for ~5 days against a live Entra → Snowflake External OAuth provider. Full details + AI-assistance disclosure are in the PR description.

@datadog-coder

This comment has been minimized.

@code-asher

Copy link
Copy Markdown
Member

Thank you for the PR! I will take a look soon, in the meantime I let CI run.

@code-asher code-asher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing some CI issues but the fix makes sense to me. Kinda feels like a bug with Entra but what can ya do.

Comment thread coderd/externalauth/externalauth.go Outdated
…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().
@dallinstevens
dallinstevens requested a review from code-asher July 8, 2026 03:32

@code-asher code-asher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome thank you! I threw on some comments about why we need the override, will merge once CI passes.

@code-asher
code-asher merged commit 37558fc into coder:main Jul 9, 2026
27 of 28 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 9, 2026
@dallinstevens
dallinstevens deleted the fix/external-auth-refresh-scope-narrowing branch July 9, 2026 21:54
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

community Pull Requests and issues created by the community. stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants