Repository navigation
Conversation
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.
|
Review round 1 (Codex GPT-6 Astra, head P2 1. POST authorization requests bypassed the limiter. Fixed in P2 2. The limiter tests did not prove the shared counter. Fixed in P2 3. Two mutants failed before their declared assertions. Fixed in
Verification.
The table is in the PR body. The #795 classifier limit stays as accepted. |
|
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:
The review loop for this PR is complete. |
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,roleandmust_change_passwordclaims into the token. OpenIddict validation authenticates the token duringUseAuthentication. 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
AcceptOAuthTokens(scopes)insrc/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, theoauth-apirate-limit policy, and a scope requirement. An opted-in endpoint accepts OAuth tokens only. Every other endpoint accepts session JWTs only.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 noOAuth:Issuer), no policy scheme is registered and JWT bearer stays the default, exactly as before.credential_epoch. A role change bumps the epoch, so the token gets 401Auth.CredentialsSupersededon its next request and the assistant must reconnect. Option 2 would need a new Access contract and a second freshness mechanism.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 getsunsupported_grant_type.MustChangePasswordMiddleware, and every later change to the flag bumps the epoch. The claim is still copied, so the token matches a session JWT.Rate limits
All three policies run on the shared
IFixedWindowCounter(#543/#544) throughDistributedFixedWindowPolicy. That is the formerDistributedIpFixedWindowPolicy, renamed and given an optional partition-key function. Each policy is configurable underRateLimiting:<Name>:PermitLimitandWindowSeconds, and has a default, so no new required key exists. The sim harness and AppHost need no change.oauth-tokenRateLimitingOptions.OAuthTokenPolicyNamePOST /api/v1/oauth/tokenoauth-authorizeRateLimitingOptions.OAuthAuthorizePolicyNameGET /api/v1/oauth/authorizeoauth-apiRateLimitingOptions.OAuthApiPolicyNameAcceptOAuthTokensendpointUseRateLimiterruns before authentication, sooauth-apikeys on the raw bearer. It hashes the exact slice OpenIddict validation extracts afterBearer, 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/mcpendpoint. That attachesoauth-api, routes its bearer to OpenIddict validation, and requires one of the named scopes. Do not callRequireRateLimitingseparately, because an endpoint holds one rate-limit policy and a second call would replace this one. If/mcpmust also accept session JWTs, the selector inCluckworkIdentityServiceCollectionExtensionsis the place to change.OpenIddict also accepts a POSTed authorization request. A POST matches no endpoint, so it would escape
oauth-authorizeand any body cap while OpenIddict still parsed it and looked the client up. A server handler now refuses any non-GET authorization request withinvalid_requestbefore 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 anIdempotency-Key.For reviewers
OAuthEndpoints.cs, then the selector inCluckworkIdentityServiceCollectionExtensions.csand the validation changes inOAuthServerRegistration.cs.tests/Cluckwork.Api.IntegrationTests/OAuthFailClosedTests.cshas one test per check. Its probes are mapped through the__EndpointRouteBuilderproperty afterProgram.csbuilds 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.OAuthToken_ForcedThroughTheDefaultScheme_IsStillRejected) is deleted. This PR lifts that wall on purpose. The wall-1 test still holds: an OAuth token on/api/v1/megets 401invalid_token.docs/decisions/796-oauth-fail-closed.md, with a new rule insrc/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 toArchitecture|Documentation|TenantBypass: 467 of 467.65f86d56: 27 of 27, including 18 new tests inOAuthFailClosedTests.02fc6162: 51 of 51.Mutations.
tools/oauth/mutation-check.shnow covers #795 and #796. It runs theOAuthclasses 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 at65f86d56: baseline 27/27, 30 killed, restore 27/27. The three local-limiter rows came back INCONCLUSIVE because their replacement text did not compile.5477d9f3fixes 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.production-gateProduction_RunsNoAuthorizationServerExpected: NotFoundpkce-optionalAuthorizationRequestWithoutPkce_IsRefusedExpected: BadRequestplain-pkce-allowedPlainCodeChallenge_IsRefusedExpected: BadRequestself-contained-tokensAuthorizationCodeWithPkce_IssuesAReferenceTokenTheResourceSideAcceptsthe access token has no ReferenceIddefault-token-lifetimethe access token carries expires_inper-process-token-keysCodeAndToken_CrossReplicas_ThroughTheSharedKeyRingthe second replica refused the first replica's codeopenid-grantedOpenIdScope_IsRefusedExpected: BadRequestverifier-loggedProtocolSecrets_NeverReachTheLogprotocol secrets reached the logparent-override-onlyprotocol secrets reached the logoauth-on-default-schemeOAuthToken_IsRejectedByBusinessEndpoints_AtAuthenticationExpected: Unauthorizedjwt-by-token-shapeSessionJwt_IsRefused_WhereOAuthTokensAreAcceptedExpected: Unauthorizedauthenticate-at-authorizationUseAuthentication, so the chain runs (disabled user)DisabledUser_IsRefused_OnTheNextRequestExpected: Unauthorizedauthenticate-at-authorization-flocksWorker_IsFlockScopedthe worker's OAuth caller is unrestrictedrole-claim-droppedOAuthToken_CarriesTheSessionPrincipal_ThroughTheWholeChainCollections differdisabled-uncheckedDisabledUser_IsRefused_OnTheNextRequestExpected: Unauthorizedsuspended-uncheckedSuspendedFarm_IsRefused_OnTheNextRequestExpected: Unauthorizedepoch-exempts-oauthRoleChange_RevokesTheTokenExpected: Unauthorizedepoch-exempts-oauth-suspendedSuspendedFarm_IsRefused_OnTheNextRequestExpected: Unauthorizedmust-change-may-authorizeMustChangePassword_BlocksIssuanceExpected: Forbiddenscope-gate-removedScopeAndRole_AreBothRequiredExpected: Forbiddenauthorization-uncheckedDisconnect_RefusesTheAccessToken_OnTheNextRequestExpected: Unauthorizedunbound-token-trustedTokenWithoutAnAuthorization_IsRefusedExpected: Unauthorizedrefresh-grant-allowedDisconnect_LeavesNoWayToANewTokenunsupported_grant_typequery-string-tokenTokenInTheQueryString_IsIgnoredExpected: Unauthorizedapi-limit-removedOAuthApiCalls_AreRateLimited_PerTokenCollections differapi-limit-per-ipExpected: OKtoken-limit-removed/tokenis rate limitedTokenEndpoint_IsRateLimitedExpected: TooManyRequestsauthorize-limit-removed/authorizeis rate limitedAuthorizeEndpoint_IsRateLimitedExpected: TooManyRequestsambient-bearer-honoured/tokenignores an ambient session bearerTokenEndpoint_IgnoresAnAmbientSessionBearerExpected: OKoauth-token-local-limiteroauth-tokencounts in the shared storeOAuthLimits_AreDecidedByTheSharedCounteroauth-token did not ask the shared counteroauth-authorize-local-limiteroauth-authorizecounts in the shared storeoauth-authorize did not ask the shared counteroauth-api-local-limiteroauth-apicounts in the shared storeoauth-api did not ask the shared counterpost-authorize-allowedAuthorizationPost_IsRefusedBeforeOpenIddictReadsItSub-string not foundEach local-limiter mutant swaps one OAuth policy for ASP.NET's in-process
AddFixedWindowLimiterwith the same limits. The guard injects anIFixedWindowCounterthat 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 theoauth-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: UnauthorizedandCollections 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_LeavesNoWayToANewTokenpins 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 checksReferenceIdbefore it probes the resource.Not done here, for the coordinator's batch
oauth-apibucket and costs one token-table lookup. A per-IP ceiling beside the per-token key would close it. Valid tokens cannot dodge their bucket.WWW-Authenticate: Bearer error="invalid_token", and a scope denial uses the role-denial 403 body. An MCP client may need both to know it must reconnect or lacks a scope. That belongs to MCP slice 3: map /mcp, wire RBAC and tools/list filtering #806.TryRevokeAsyncreturns false on a concurrency failure, and OpenIddict's tables carry noAccountId. The action must check the result and must confirm the authorization's subject is the caller or a user on the Owner's farm.OnRejectedlogs the alertableRateLimitRejectedevent for login and refresh only, not for the three OAuth policies.