Skip to content

Forward a per-audience access token for the signed-in user to backends - #171

Merged
woksin merged 24 commits into
mainfrom
feature/155-forward-user-access-tokens
Oct 2, 2026
Merged

woksin merged 24 commits into
mainfrom
feature/155-forward-user-access-tokens

Conversation

@woksin

@woksin woksin commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Added

  • Forward the signed-in user's access token to a service's backend for its own audience by setting AccessToken.Scopes or AccessToken.Resource. Tokens remain server-side, and cached and renewed per session and audience. Backend requests receive Authorization: Bearer, or 401 when no token can be obtained. Requests that capture mismatched token policies and destinations during a reload receive 503 before a token is obtained; retry after the reload completes. Frontend routes, anonymous paths and machine callers with their own bearer token remain unchanged. See Forwarding the user's access token. (Forward a per-audience access token for the signed-in user to backends #155)
  • Keep token sessions active with authenticated cookie activity when Session.SlidingExpiration is enabled, remove them on logout, and back off for 30 seconds per audience after invalid_grant. (Forward a per-audience access token for the signed-in user to backends #155)

woksin added 2 commits October 1, 2026 13:38
An OIDC provider can now authenticate AuthProxy to its token endpoint with a
private_key_jwt client assertion signed by a certificate (file, certificate
store or Azure Key Vault), or with a federated token (workload identity token
file or Azure managed identity), instead of a client secret. The credential
loaders come from Microsoft.Identity.Web; ClientSecret stays the default and
configuring both fails at startup.
A service can declare AccessToken scopes (or an RFC 8707 resource). AuthProxy
keeps the refresh token issued at sign-in server-side, keyed by an unguessable
reference inside the encrypted session cookie, redeems it at the provider's
token endpoint for the service's audience (client secret or client assertion),
caches the access token per session and audience until shortly before expiry,
and forwards it as Authorization: Bearer on requests to the service's backend.
Requests that cannot get a token are refused with 401.
@woksin woksin self-assigned this Oct 1, 2026
@woksin woksin added the minor label Oct 1, 2026
@woksin

woksin commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Notes for reviewers (not part of the release note):

Stacked on #167 (base feature/149-oidc-client-credentials). Refreshing a user's token has to authenticate to the token endpoint exactly as the sign-in did. A provider configured with a certificate or federated credential would otherwise not work, so this reuses #167's IOidcClientAssertions. Merge #167 first, then retarget this PR to main.

Design decisions (conservative choices; flag any you want changed)

  • Refresh tokens stay server-side, not in the cookie. SaveTokens stays false. At OnTokenResponseReceived the refresh token goes into an IUserTokenStore (IDistributedCache, values Data Protection-encrypted, cache keys are SHA-256 of the reference). The cookie ticket carries only a 256-bit random reference in AuthenticationProperties.Items. Duende BFF's default keeps tokens in the encrypted cookie instead; I chose the stricter reading of "server-side only".
  • Consequence / open question: the default cache is in-memory, so tokens do not survive restarts and are not shared across replicas (documented: sticky sessions, or users sign in again). A pluggable shared store (Redis), or an opt-in encrypted-cookie mode, is a product decision for a follow-up.
  • Token acquisition is the standard OAuth refresh-token grant (RFC 6749 §6) with scope and optional RFC 8707 resource. Client auth is the provider's secret or client assertion. Rotated refresh tokens are stored. Tokens are cached per session and audience (hash of scheme + sorted scopes + resource) and renewed 60 s before expiry (300 s is assumed when expires_in is missing). Refreshes are serialized per session within an instance with 64 striped locks. invalid_grant discards the session's refresh token. Microsoft.Identity.Web's ITokenAcquisition was not used: it requires its own AddMicrosoftIdentityWebApp handler setup, which conflicts with AuthProxy's provider registration and is Entra-only.
  • Fail closed: no token means 401 (no redirect, to avoid a sign-in loop when a provider never issues refresh tokens). The ID token is never forwarded. The token replaces any inbound Authorization only on cookie-session requests routed to the service's backend cluster. Frontend, anonymous routes and CC/JWT-bearer callers are untouched. The selected default scheme is recorded in HttpContext.Items by ResolveAuthenticationScheme, so a bearer-authenticated request carrying a cookie never gets the cookie user's token.
  • Forwarding runs in YARP's proxy pipeline (MapReverseProxy(proxy => …) with the default session-affinity, load-balancing and passive-health stages re-added), after cluster selection. Clusters now carry service/endpoint metadata.
  • Sign-out (every path goes through cookie SignOutAsync) removes the stored tokens via OnSigningOut. The reference is stashed in OnValidatePrincipal, because calling AuthenticateAsync inside OnSigningOut can deadlock when sign-out is triggered from principal validation.
  • Overlap with Route to services by host or path prefix #169 (routing): both touch MicroserviceReverseProxyConfigProvider (different regions) and ReverseProxyExtensions. Expect a small merge.

Local gate (mirrors CI): dotnet build -c Debug ✅ and -c Release ✅ (0 warnings), dotnet test -c Debug --no-build ✅ (Aspire 74, Security 250, AuthProxy 2269), security specs -c Release ✅, dotnet publish -c Release ✅. I did not build the Docker image locally. Not exercised against a live identity provider: the refresh grant is covered against a stand-in token endpoint, and the proxy-pipeline wiring end to end with a stand-in token source.

This is security-sensitive and needs a cross-provider review before merge.

woksin added 15 commits October 1, 2026 16:07
…ser-access-tokens

# Conflicts:
#	Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
…ser-access-tokens

# Conflicts:
#	Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
#	Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs
@woksin
woksin changed the base branch from feature/149-oidc-client-credentials to main October 1, 2026 22:24
@woksin

woksin commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both confirmed findings: merged current origin/main first, then incorporated the current #167 credential branch without replacing its signing, certificate reload, sovereign-cloud or password fixes. #167 is still open, so its changes remain in the main-based diff; the release note describes only #155.

Token policy now comes from the selected cluster's immutable metadata. Backend cluster and destination IDs are versioned by address and full token policy. Added unit coverage for policy-only/address-only reloads and immutable policies, plus a running-YARP regression that blocks token acquisition, changes both backend and audience, and checks which origin receives each token.

Targeted local checks via pi-phase:

  • dotnet build Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release -warnaserror: passed (includes the production project), zero warnings/errors.
  • dotnet build Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release -warnaserror: passed, zero warnings/errors. Initial admission timed out once; the allowed retry was admitted. Analyzer findings encountered during implementation were corrected before the final passing build.
  • dotnet test Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Debug: passed, 2597 specs.
  • dotnet test Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Debug --filter FullyQualifiedName~for_AccessTokenForwarding: passed, 7 specs, including the reload regression.
  • git diff --check: passed.

No whole-solution local gates were run, per the owner instruction. The PR's CI remains the full Debug/Release and complete-spec gate; it was not watched or awaited. No merge or label changes were made.

@woksin

woksin commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the confirmed routing regressions in e081b20. Backend cluster IDs keep the existing {service}-backend-cluster convention; destination IDs alone are versioned by backend address and token policy, and the selected cluster metadata retains the immutable policy. An unresolved selected proxy endpoint now fails closed for client credentials instead of falling back to caller selection.

Added security regressions with real generated proxy routes: root and service claim requirements permit the authorized cookie caller and forward its bearer token; machine tokens are accepted on their own service and rejected on another token-forwarding backend selected by host or prefix, including conflicting header/query selection. The existing in-flight reload regression still passes. Fixtures explicitly declare identity verification mode without weakening assertions.

Local checks used pi-phase as requested:

  • dotnet build Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release -warnaserror: passed, zero warnings/errors; also builds the changed AuthProxy gateway. Initial admission timed out at 600 seconds, the permitted retry was admitted; an analyzer formatting error was fixed before the final passing build.
  • dotnet build Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release -warnaserror: passed, zero warnings/errors.
  • dotnet test Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release --no-build --filter FullyQualifiedName~for_AccessTokenForwarding: passed, 14 specs. A first run exposed an invalid fixture route prefix; corrected it to the actual token-scoped route prefix and retained the positive-control assertions.
  • dotnet test Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release --no-build --filter 'FullyQualifiedName~ClientCredentialsServiceResolver|FullyQualifiedName~MicroserviceReverseProxyConfigProvider|FullyQualifiedName~AccessTokenForwardingMiddleware|FullyQualifiedName~AccessPolicy': skipped because pi-phase did not admit it within 120 seconds; not retried.
  • git diff --check: passed.

Whole-solution builds, the remaining spec suites, Docker/publish checks and dependency audits were intentionally left to the PR's CI per the owner's targeted-only local check decision. CI was not watched or awaited. No merge or label changes.

The current diff against origin/main still includes the OIDC credential feature from #149, and #167 is open. The release note now includes that feature rather than relying on merge order. The unreleased token-binding guarantee is in Added, not Security.

@woksin

woksin commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Merged current origin/main (including #167) and fixed both confirmed reports of the policy/destination reload race. Backend cluster metadata now carries the versioned destination binding. Cookie-session token forwarding checks both AvailableDestinations and AllDestinations against it before token acquisition and returns 503 for a mismatch or missing/unavailable binding. Service resolution and machine-caller behavior remain unchanged.

Added deterministic unit specs that freeze the new-policy/old-destination snapshot between YARP's separate publications, plus coverage for independently stale destination lists, missing metadata, unavailable destinations and the provider's metadata binding. The regression constructs that intermediate snapshot directly; it does not instrument YARP's internal configuration manager.

Local checks:

  • PASS: pi-phase run --kind build --timeout 300 --queue-timeout 600 -- dotnet build Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release -warnaserror — builds both changed projects with analyzers; zero warnings and errors.
  • SKIPPED: pi-phase run --kind test --timeout 120 --queue-timeout 120 -- dotnet test Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Debug -warnaserror — queue timeout (exit 75), command never started; no retry per owner instructions.
  • PASS: git diff --check origin/main...HEAD.

Whole-solution Debug/Release builds, remaining spec projects and Docker checks are left to this PR's CI as requested; CI was not watched. Release notes were checked against the current diff, updated to describe 503 during reload mismatches, and the already-merged #149 bullet was removed. One push: 6271850. No merge or label changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant