Skip to content

feat(api): run every fail-closed check on OAuth-authenticated requests - #1136

Open
mforce wants to merge 8 commits into
mainfrom
feat/796-oauth-fail-closed
Open

mforce wants to merge 8 commits into
mainfrom
feat/796-oauth-fail-closed

Conversation

@mforce

@mforce mforce commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Closes #796

What this does

An OAuth access token now runs the same per-request checks as a session JWT. The authorize endpoint copies the session principal's sub, email, account_id, credential_epoch, role and must_change_password claims into the token. OpenIddict validation authenticates the token during UseAuthentication. Tenant resolution, flock scope, the credential-epoch check (disabled user, suspended farm, stale epoch) and must-change-password then run unchanged.

No business endpoint accepts an OAuth token yet, and this PR maps no /mcp. Two test-only probes prove the checks. They sit in the real endpoint table, behind every middleware a business endpoint has.

Decisions the owner should know about

  • Which endpoints accept OAuth tokens. None in production code. An endpoint opts in with AcceptOAuthTokens(scopes) in src/Cluckwork.Api/Modules/Access/OAuth/OAuthEndpoints.cs. That extension is the only way in, because its marker type is private. It attaches three things: the marker, the oauth-api rate-limit policy, and a scope requirement. An opted-in endpoint accepts OAuth tokens only. Every other endpoint accepts session JWTs only.
  • Scheme coexistence. The default scheme is now a policy scheme, Cluckwork.Bearer. It reads the matched endpoint's metadata, never the token's shape. Opted-in endpoints go to OpenIddict validation. All others go to the JWT bearer handler. Neither handler ever receives the other's token. When the OAuth server is off (Production, or no OAuth:Issuer), no policy scheme is registered and JWT bearer stays the default, exactly as before.
  • Scopes and roles. The scope requirement is a second authorization policy beside the endpoint's role policy, so effective permission is role ∩ scope by composition. No product scope is registered. OAuth slice 4: consent screen, with step-up and return-URL validation #798 registers the two from Build an OAuth 2.1 authorization server (OpenIddict) so MCP clients can authenticate #788, and MCP slice 3: map /mcp, wire RBAC and tools/list filtering #806 maps tools to them. The authorize stub grants whatever registered scopes the client asks for, which today is none.
  • Role freshness: option 1. The token carries credential_epoch. A role change bumps the epoch, so the token gets 401 Auth.CredentialsSuperseded on its next request and the assistant must reconnect. Option 2 would need a new Access contract and a second freshness mechanism.
  • What Disconnect revokes: the authorization. Authorize creates an ad-hoc authorization, and every code and access token carries its id. Validation now calls EnableAuthorizationEntryValidation(), so the next request with a revoked authorization's token gets 401. OpenIddict skips that check when a token names no authorization, so an inline handler refuses such a token. A code issued before Disconnect no longer redeems. There is no refresh grant (OAuth slice 1: stand up OpenIddict — tables, migration, token issuance #795 kept tokens indefinite), so a refresh attempt gets unsupported_grant_type.
  • Must-change-password is enforced at issuance. The authorize endpoint sits behind MustChangePasswordMiddleware, and every later change to the flag bumps the epoch. The claim is still copied, so the token matches a session JWT.
  • Header-only tokens. Validation no longer reads a token from the query string, which reaches request logs, or from a form body, which would dodge the per-token rate-limit key.

Rate limits

All three policies run on the shared IFixedWindowCounter (#543/#544) through DistributedFixedWindowPolicy. That is the former DistributedIpFixedWindowPolicy, renamed and given an optional partition-key function. Each policy is configurable under RateLimiting:<Name>:PermitLimit and WindowSeconds, and has a default, so no new required key exists. The sim harness and AppHost need no change.

Policy name Constant Applied to Key Default
oauth-token RateLimitingOptions.OAuthTokenPolicyName POST /api/v1/oauth/token client IP 20 per 60 s
oauth-authorize RateLimitingOptions.OAuthAuthorizePolicyName GET /api/v1/oauth/authorize client IP 20 per 60 s
oauth-api RateLimitingOptions.OAuthApiPolicyName every AcceptOAuthTokens endpoint SHA-256 of the bearer 120 per 60 s

UseRateLimiter runs before authentication, so oauth-api keys on the raw bearer. It hashes the exact slice OpenIddict validation extracts after Bearer , so a valid token cannot dodge its bucket. With no refresh grant, one token is one connection. A request with no bearer falls back to the client IP.

For #806. Call .AcceptOAuthTokens(<scopes>) on the /mcp endpoint. That attaches oauth-api, routes its bearer to OpenIddict validation, and requires one of the named scopes. Do not call RequireRateLimiting separately, because an endpoint holds one rate-limit policy and a second call would replace this one. If /mcp must also accept session JWTs, the selector in CluckworkIdentityServiceCollectionExtensions is the place to change.

OpenIddict also accepts a POSTed authorization request. A POST matches no endpoint, so it would escape oauth-authorize and any body cap while OpenIddict still parsed it and looked the client up. A server handler now refuses any non-GET authorization request with invalid_request before OpenIddict reads the body.

The token endpoint now goes through OpenIddict's passthrough, so it is a mapped endpoint that can carry the policy, a body cap of 8192 bytes and IgnoresAmbientPrincipal. Without that marker, a client that also sent a session bearer resolved a tenant and was refused for lacking an Idempotency-Key.

For reviewers

  • Start with OAuthEndpoints.cs, then the selector in CluckworkIdentityServiceCollectionExtensions.cs and the validation changes in OAuthServerRegistration.cs.
  • tests/Cluckwork.Api.IntegrationTests/OAuthFailClosedTests.cs has one test per check. Its probes are mapped through the __EndpointRouteBuilder property after Program.cs builds its endpoint table. That reaches into a framework property name, but it is the only way I found to put a test endpoint behind the real middleware chain.
  • OAuth slice 1: stand up OpenIddict — tables, migration, token issuance #795's wall-2 test (OAuthToken_ForcedThroughTheDefaultScheme_IsStillRejected) is deleted. This PR lifts that wall on purpose. The wall-1 test still holds: an OAuth token on /api/v1/me gets 401 invalid_token.
  • The rationale is in docs/decisions/796-oauth-fail-closed.md, with a new rule in src/AGENTS.md.

Proof

Targeted tests, run locally on the branch. I ran filtered tests only, never the full suite.

  • Cluckwork.Api.IntegrationTests, filtered to OAuth, rate limiting, body-reading endpoints, credential epoch, must-change-password, process roles, one-shot minimal config, serving guard coverage, auth endpoints, ambient principal, idempotency, flock scope, schema docs and auth body limits: 291 tests, all passing after the body-reading fix.
  • Cluckwork.Application.Tests, filtered to Architecture|Documentation|TenantBypass: 467 of 467.
  • The OAuth classes alone at 65f86d56: 27 of 27, including 18 new tests in OAuthFailClosedTests.
  • The OAuth, body-reading and rate-limit classes at 02fc6162: 51 of 51.

Mutations. tools/oauth/mutation-check.sh now covers #795 and #796. It runs the OAuth classes as its baseline (27 tests, at least 27 required), then applies each mutant, rebuilds, runs the one named test and classifies the TRX as #795 describes. The full table ran at 65f86d56: baseline 27/27, 30 killed, restore 27/27. The three local-limiter rows came back INCONCLUSIVE because their replacement text did not compile. 5477d9f3 fixes only those rows in the script, and a run of just those three at that head killed all three (baseline and restore 27/27). Product and test code are identical at both heads.

Mutant Claim it breaks Named test Declared failure text Verdict
production-gate Production runs no OAuth server Production_RunsNoAuthorizationServer Expected: NotFound killed
pkce-optional PKCE is required AuthorizationRequestWithoutPkce_IsRefused Expected: BadRequest killed
plain-pkce-allowed only S256 is accepted PlainCodeChallenge_IsRefused Expected: BadRequest killed
self-contained-tokens access tokens are reference tokens AuthorizationCodeWithPkce_IssuesAReferenceTokenTheResourceSideAccepts the access token has no ReferenceId killed
default-token-lifetime access tokens live until revoked same the access token carries expires_in killed
per-process-token-keys codes and tokens use the shared ring CodeAndToken_CrossReplicas_ThroughTheSharedKeyRing the second replica refused the first replica's code killed
openid-granted no identity token is issued OpenIdScope_IsRefused Expected: BadRequest killed
verifier-logged no protocol secret reaches a log sink ProtocolSecrets_NeverReachTheLog protocol secrets reached the log killed
parent-override-only the log floor holds against a child override same protocol secrets reached the log killed
oauth-on-default-scheme an OAuth token never authenticates a business endpoint (routing by token shape) OAuthToken_IsRejectedByBusinessEndpoints_AtAuthentication Expected: Unauthorized killed
jwt-by-token-shape a session JWT never authenticates an OAuth endpoint SessionJwt_IsRefused_WhereOAuthTokensAreAccepted Expected: Unauthorized killed
authenticate-at-authorization OAuth authenticates in UseAuthentication, so the chain runs (disabled user) DisabledUser_IsRefused_OnTheNextRequest Expected: Unauthorized killed
authenticate-at-authorization-flocks same, seen through flock scope Worker_IsFlockScoped the worker's OAuth caller is unrestricted killed
role-claim-dropped the token carries the session roles OAuthToken_CarriesTheSessionPrincipal_ThroughTheWholeChain Collections differ killed
disabled-unchecked a disabled user is refused DisabledUser_IsRefused_OnTheNextRequest Expected: Unauthorized killed
suspended-unchecked a suspended farm is refused SuspendedFarm_IsRefused_OnTheNextRequest Expected: Unauthorized killed
epoch-exempts-oauth a role change revokes the token RoleChange_RevokesTheToken Expected: Unauthorized killed
epoch-exempts-oauth-suspended the epoch middleware runs for OAuth callers SuspendedFarm_IsRefused_OnTheNextRequest Expected: Unauthorized killed
must-change-may-authorize a pending password change blocks issuance MustChangePassword_BlocksIssuance Expected: Forbidden killed
scope-gate-removed scope and role are both required ScopeAndRole_AreBothRequired Expected: Forbidden killed
authorization-unchecked Disconnect refuses the access token Disconnect_RefusesTheAccessToken_OnTheNextRequest Expected: Unauthorized killed
unbound-token-trusted a token naming no authorization is refused TokenWithoutAnAuthorization_IsRefused Expected: Unauthorized killed
refresh-grant-allowed there is no refresh grant Disconnect_LeavesNoWayToANewToken unsupported_grant_type killed
query-string-token only the header carries a token TokenInTheQueryString_IsIgnored Expected: Unauthorized killed
api-limit-removed OAuth endpoints are rate limited OAuthApiCalls_AreRateLimited_PerToken Collections differ killed
api-limit-per-ip the API limit keys per token same Expected: OK killed
token-limit-removed /token is rate limited TokenEndpoint_IsRateLimited Expected: TooManyRequests killed
authorize-limit-removed /authorize is rate limited AuthorizeEndpoint_IsRateLimited Expected: TooManyRequests killed
ambient-bearer-honoured /token ignores an ambient session bearer TokenEndpoint_IgnoresAnAmbientSessionBearer Expected: OK killed
oauth-token-local-limiter oauth-token counts in the shared store OAuthLimits_AreDecidedByTheSharedCounter oauth-token did not ask the shared counter killed
oauth-authorize-local-limiter oauth-authorize counts in the shared store same oauth-authorize did not ask the shared counter killed
oauth-api-local-limiter oauth-api counts in the shared store same oauth-api did not ask the shared counter killed
post-authorize-allowed a POSTed authorization request is refused AuthorizationPost_IsRefusedBeforeOpenIddictReadsIt Sub-string not found killed

Each local-limiter mutant swaps one OAuth policy for ASP.NET's in-process AddFixedWindowLimiter with the same limits. The guard injects an IFixedWindowCounter that refuses every increment and records its keys. It asserts that each of the three policies answers 429 on its first request, and that the store saw the oauth-api:token:<sha256> key for the caller's token.

#795's classifier limit still applies. A declared text proves which assertion failed, not why. Two generic texts, Expected: Unauthorized and Collections differ, are the weakest rows.

One check has no mutant in this repo's code. A code from a revoked authorization no longer redeems because of OpenIddict's own server-side check. Turning that check off means disabling authorization storage, which the validation side refuses at boot. Disconnect_LeavesNoWayToANewToken pins the behaviour across OpenIddict upgrades.

Self-contained tokens now fail closed. With UseLocalServer, OpenIddict reads token entries, and so authorization ids, only for reference tokens. A switch to self-contained tokens would therefore skip the Disconnect check, and the authorization-id handler refuses those tokens instead. That is why the #795 test now checks ReferenceId before it probes the resource.

Not done here, for the coordinator's batch

mforce added 8 commits October 8, 2026 06:07
OAuth access tokens now authenticate during UseAuthentication into the
same claims a session JWT carries, so tenant, flock scope, credential
epoch and must-change-password apply unchanged. A policy scheme routes
each endpoint to one handler: OpenIddict validation where it opted in
through AcceptOAuthTokens, the session JWT scheme elsewhere. Every
request checks the token's authorization, and a token naming none is
refused. Shared-counter rate limits cover /oauth/token, /oauth/authorize
and OAuth-accepting endpoints (keyed per token).

Refs #796
…t reads them

A POST to /api/v1/oauth/authorize matched no endpoint, so it escaped the
oauth-authorize rate limit and any body cap while OpenIddict still parsed
it and looked the client up.

Refs #796
Adds the POST-authorize and shared-counter guards with their mutants, and
keeps two mutants from failing before their declared assertions: the
business-routing mutant now routes by token shape, so issuance stays on
the session scheme, and the reference-token check runs before the probe.
@mforce

mforce commented Oct 8, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 (Codex GPT-6 Astra, head 5977fdc1): all three P2 findings are fixed. The branch now ends at 5477d9f3.

P2 1. POST authorization requests bypassed the limiter. Fixed in 987615e3. I chose to refuse rather than limit. An OpenIddict server handler, ordered before ExtractGetOrPostRequest, rejects any non-GET authorization request with invalid_request. The body is never read and no client is looked up, so neither a rate-limit policy nor a body cap is needed for POST. AuthorizationPost_IsRefusedBeforeOpenIddictReadsIt sends a well-formed POST from a signed-in user and asserts the refusal. Its mutant, post-authorize-allowed, deletes the check, and the test fails with Sub-string not found.

P2 2. The limiter tests did not prove the shared counter. Fixed in 02fc6162. OAuthLimits_AreDecidedByTheSharedCounter replaces IFixedWindowCounter with a double that refuses every increment and records its keys. It asserts that /token, /authorize and an OAuth-accepting endpoint each answer 429 on the first request, and that the store saw oauth-api:token:<sha256 of the token>. Three mutants swap one OAuth policy each for ASP.NET's in-process AddFixedWindowLimiter with the same limits. All three are killed with <policy> did not ask the shared counter.

P2 3. Two mutants failed before their declared assertions. Fixed in 02fc6162.

  • oauth-on-default-scheme now routes by token shape: every non-JWT bearer goes to OpenIddict, on every endpoint. Issuance stays on the session scheme, so the test reaches /api/v1/me and fails with Expected: Unauthorized.
  • OAuth slice 1: stand up OpenIddict — tables, migration, token issuance #795's reference-token test now checks ReferenceId and the stored row before it probes the resource, so self-contained-tokens fails with the access token has no ReferenceId. The earlier failure also showed that self-contained tokens now fail closed. With UseLocalServer, OpenIddict reads token entries, and so authorization ids, only for reference tokens, and the new handler refuses a token naming no authorization.

Verification.

  • OAuth classes at 65f86d56: 27 of 27.
  • OAuth, body-reading and rate-limit classes at 02fc6162: 51 of 51.
  • Full mutation table at 65f86d56: baseline 27/27, 30 killed, restore 27/27. The three local-limiter rows came back INCONCLUSIVE because their replacement text did not compile. 5477d9f3 fixes only those script rows, and a run of just those three killed all three, with baseline and restore 27/27. Product and test code are identical at both heads.
  • CI passed in full at 65f86d56, including the integration leg, Playwright smoke and CodeQL.

The table is in the PR body. The #795 classifier limit stays as accepted.

@mforce

mforce commented Oct 8, 2026

Copy link
Copy Markdown
Owner Author

Review round 2 by Codex GPT-6 Astra, run locally on head 5477d9f, found 0 P1, 0 P2 and 0 P3. It confirmed all three round-1 findings are addressed:

  • Every non-GET authorization request is refused before OpenIddict reads the body or looks up the client.
  • The OAuth limiter tests now fail when a policy uses a process-local limiter.
  • The two restructured mutants reach their intended assertions.

The review loop for this PR is complete.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth slice 2: fail-closed checks on the OAuth path (disabled, suspended, must-change-password, flock scope, rate limit)

1 participant