Repository navigation
feat(api): stand up OpenIddict as an OAuth 2.1 authorization server - #1135
Conversation
Authorization code with PKCE and reference access tokens that live until revoked, on the Data Protection format so the shared key ring (#794) protects codes and tokens. Registered only for a serving process outside Production with OAuth:Issuer set, until client registration (#797) and consent with step-up (#798) exist. Adds the four OpenIddict tables in one migration, regenerated schema docs, and their table-owner, tenant-bypass and record-census entries. Refs #795
|
Review round 1 came from Codex GPT-6 Astra, at P2: PKCE verifiers reached the application log. Fixed in
P2: the mutation runner counted infrastructure failures as kills. Fixed in
|
|
Review round 2 came from Codex GPT-6 Astra, at P2: a more specific override still enabled the verifier dump. Fixed in
P2: baseline and restore ignored run-level failure. Fixed in
P2: an unrelated server error can count as killing the key-sharing mutant. Recorded, not fixed, on the maintainer's decision. The PR body now names this as a known limit of the runner. A declared text proves that the named assertion failed, not why. It matches an unrelated HTTP 500, Verification. The rerun at The review loop stops here, on purpose. The maintainer chose to end it after round 2. Round 1 found a product defect: the verifier leak. Round 2's findings were a route that needs a deliberate configuration change, and the mutation tooling itself. No round 3 will be requested. |
Closes #795
What this does
Stands up OpenIddict 7.7.1 as an OAuth 2.1 authorization server for third-party clients. It supports the authorization-code flow with PKCE, and issues reference access tokens that last until revoked (#788). Users see nothing yet. A script-style integration test completes the flow and calls a test-only resource endpoint with the token.
Production cannot issue a token.
AddCluckworkIdentityregisters OpenIddict only for a serving process outside Production that hasOAuth:Issuerset. A Production host maps no OAuth endpoint and resolves no OpenIddict service, even when an issuer is configured. #797 (client registration) and #798 (consent with step-up) lift that gate.No OAuth token reaches a business endpoint. Two walls stand until #796 adds the real checks:
suband nothing else. Forced through the default scheme, it has noaccount_id, soTenantResolutionMiddlewarereturns 401. If it did carry anaccount_id, the missingcredential_epochwould read as epoch 0, whichCredentialEpochMiddlewarenever accepts (Credential epoch: per-request revocation check, deployed inert ahead of the user-admin mutations #364).This does rely on
CredentialEpochMiddlewarerejecting a principal with nocredential_epoch, but only as the second line behind tenant resolution. Theaccount-id-in-tokenmutant below shows that line holding.Decisions the maintainer should know about
OAuth:Issueris optional. Unset, the server stays off.appsettings.Development.jsonsetshttp://localhost:8080/fordotnet runand the AppHost, and the test factory setshttps://localhost/. Production ignores the key for now, so the sim harness (Sim harness (#243) rotted silently: 4 breakages, no CI ever ran it #370) andsrc/Cluckwork.AppHost/Program.cs(Developer experience: add an Aspire AppHost for local orchestration and observability #565) need no change. The issuer is never derived from the request Host (Custom domains: resolve the farm from the Host header (AccountDomain + /tenant/resolve) #538).ID0085,ID0086), whatever the token format. With the Data Protection format, though, codes and access tokens go through the shared Data Protection has no persisted key ring, so Identity's token providers break across restarts and replicas #794 ring, and only identity tokens use those keys. So the keys are ephemeral and per process, theopenidscope is removed and the key-set endpoint is unmapped. A cross-replica test redeems a code on a second host whose keys differ, and passes only because both hosts read the same ring.openidis refused, because the first version issued an id_token. OpenIddict registersopenidby default. ItsAttachDefaultScopeshandler grants it whenever the client asks for it and the sign-in names no scopes. A client asking foropenidtherefore got an identity token signed with a key no other replica holds.OpenIdScope_IsRefusedfound this. The fix removesopenidfrom the registered scopes, so OpenIddict answersinvalid_scope.code_verifier, so every token request put the verifier in the log (found in review round 1). Every request and response dump is at Information. At Warning and above, OpenIddict logs only row ids, key type names and exceptions.AddCluckworkTelemetryadds a Serilog filter that drops every event below Warning whose source isOpenIddictor a category under it, before any sink. An override could not hold that floor, because Serilog applies the most specific override and a configuredOpenIddict.Serveroverride beat the round-1 parent override (review round 2). No override or configuration reload changes the filter. The cost is that rejection reasons no longer reach the log. OpenIddict still returns them to the client aserror_description.OpenIddict.AspNetCoremetapackage the issue names also pulls in the OpenIddict client stack (Polly, HTTP resilience, telemetry), and CI: dependency-vulnerability and SAST gates #146 would then audit all of it. This PR referencesOpenIddict.EntityFrameworkCoreand the five server and validation packages instead. The lock files gain 12 entries instead of about 30.plainby default, and the live proof below showed discovery advertising it. Aplainchallenge is the verifier itself, so it protects nothing once the authorization request leaks. The follow-up commit removes it.authorization_endpointandtoken_endpointURLs, though, use the host the client called. In the live run, the issuer washttp://localhost:8080/while the endpoints named port 58795. That is harmless here, but OAuth slice 3: dynamic client registration with rate limiting and unapproved-app expiry #797 or OAuth slice 4: consent screen, with step-up and return-URL validation #798 should decide whether the endpoints should hang off the issuer too.IMemoryCachecaches parsed JSON keyed by content, so it holds no authority state that could differ between replicas.Guards this slice had to satisfy
TableOwnerOverridesgains fourOpenIddict*rows owned by Access.TenantBypassDiscoveryTests.DiscoveredSurface_Floornames the four entities with a stated reason. The test still checks exact set equality.PlatformBusinessRecordsexcludes the four types. The Standardize business-record timestamps and chronological list ordering #819 census refused to build the model until it did.RealModuleLedger.Adaptersneeds no row, because the endpoint reaches no module.AdapterReachRealTreeTestsagrees.ICurrentUseronly, soIAccessModuleneeded no new operation.AddOpenIddictTables, adds the tables, anddocs/schema/is regenerated. The model snapshot diff is large because EF re-sorted entity blocks that were out of name order (Commerce before Farm), not because of a schema change beyond the four tables.For reviewers
src/Cluckwork.Infrastructure/Modules/Access/OAuth/OAuthServerRegistration.csand the gate inCluckworkIdentityServiceCollectionExtensions.OAuthIssuer.tests/Cluckwork.Api.IntegrationTests/OAuthServerTests.csis the "done when" script.docs/decisions/795-openiddict-server.md, with a new rule insrc/AGENTS.md.Proof
Targeted tests, run locally. I ran filtered tests only, never the full suite. CI is the authority.
Cluckwork.Application.Tests, filtered toArchitecture|TenantBypass|BusinessRecord|TableOwner|Persistence: 447 of 447 passed. Before the ledger rows existed, exactly three of them failed:TableOwnerRealModelTests,DiscoveredSurface_Floor, andCouplingMatrixRealTreeTests, which follows from the first.Cluckwork.Application.Tests,Documentation: 20 of 20.Cluckwork.Api.IntegrationTests, the two new classes at803499d5: 10 of 10.Cluckwork.Api.IntegrationTests, 28 neighbouring classes atc6fce15a(auth, credential epoch, must-change-password, process-role guards, one-shot minimal config, fixture ports, Data Protection, body-reading endpoints, schema-doc pins, allowed hosts, security headers,/me): 286 of 286. Nine of those tests are new. I did not run the same filter onmain.Mutations.
sg docker -c 'bash tools/oauth/mutation-check.sh'first requires a green baseline. Green means the test process exited 0, the TRX run summary readsCompletedwith no run-level errors, and at least 10 tests all ran and passed. The restore run must meet the same gate. For each mutant, it applies an exact-string edit that must match once, rebuilds, and runs the one named test with a TRX logger. It classifies the result from the TRX, not from the exit code. A kill counts only if the named test failed and its message carries the declared text. A test that did not run, an aborted run, a missing TRX, or a failure on an exception instead of an assertion is INCONCLUSIVE, and fails the run. The run ends with a green restore. At803499d5the run had a 10/10 baseline, a 10/10 restore and 0 failures.production-gateProduction_RunsNoAuthorizationServerExpected: NotFoundpkce-optionalAuthorizationRequestWithoutPkce_IsRefusedExpected: BadRequestplain-pkce-allowedPlainCodeChallenge_IsRefusedExpected: BadRequestself-contained-tokensIssuesAReferenceTokenTheResourceSideAcceptsthe access token has no ReferenceIddefault-token-lifetimeIssuesAReferenceTokenTheResourceSideAcceptsthe 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-onlyProtocolSecrets_NeverReachTheLogprotocol secrets reached the logoauth-on-default-schemeOAuthToken_IsRejectedByBusinessEndpoints_AtAuthenticationAssert.Contains() Failure: Filter not matchedsession-claims-in-tokenaccount_idandcredential_epochinto the tokenOAuthToken_ForcedThroughTheDefaultScheme_IsStillRejectedExpected: Unauthorizedaccount-id-in-tokenaccount_idonlyCredentialEpochMiddlewarestill refuses epoch 0A known limit of the runner. A declared text proves that the named assertion failed, not why it failed.
the second replica refused the first replica's codealso matches an unrelated HTTP 500 from the second host, andExpected: BadRequestaccepts any actual status. The classifier also checks the declared text before it checks for an exception. So an infrastructure fault that surfaces as an HTTP response can still count as a kill. Codex GPT-6 Astra raised this in round 2, and the maintainer chose to record it rather than fix it. Read the saved logs before you trust a kill. The round-2 reviewer read them for thefbffc4e7run and found that every kill failed on its expected status.The classifier was also run on synthetic TRX files built from a real one. It returned INCONCLUSIVE for an aborted run, a missing test, a NotExecuted test, a missing TRX and an exception failure. It returned WRONG for a different assertion and SURVIVED for a passing test.
The log marker test.
ProtocolSecrets_NeverReachTheLogconfigures Serilog to let OpenIddict log everything. It setsDefault,Override:OpenIddictand the childOverride:OpenIddict.Serverto Verbose. It adds a DIILogEventSink. That sink receives the same filtered events as the configured Console sink. It then runs a redeemed exchange with a marker verifier, and a refused one with a marker verifier and a marker code. It fails if any captured event's rendered message, properties or exception contains either verifier, the marker code, the real code or the access token. It also requires the tap to have seen the token request. Without the filter it fails withprotocol secrets reached the log in 4 event(s). It fails the same way with the round-1 parent override in place of the filter, because the child override wins.Live flow on a real host. The published app ran under Kestrel in Development over plain HTTP, against a throwaway Postgres container, with no shared stack. The script ran
migrate,bootstrap-adminand a password change. It inserted a public client with SQL, because there is no registration endpoint until #797. Then it drove the flow with curl. The access token is redacted below.This run was at
c6fce15a, before the S256-only commit, which is whyplainstill appears. A rerun from an export offbffc4e7found the verifier in 0 lines of the API's real Console output, and thecode_verifierkey in 0 lines. The remaining lines that mention OpenIddict are EF SQL commands on its tables, with every parameter rendered as'?'. Replaying the code gotinvalid_grant, and OpenIddict revoked the access token issued from that code. That is why the stored row readsrevoked. The row's reference id is set and it has no expiry.CI. At
c6fce15a, CI passed in full, the integration leg included. E2E smoke and CodeQL passed too. E2E smoke boots the sim harness on Production config, which confirms the harness needed no change. Atd3ed85ceandfbffc4e7, CI, E2E smoke, CodeQL and backend coverage all passed. At803499d5, all four passed again.Not done here, for the coordinator's batch
/api/v1/oauth/tokenand/api/v1/oauth/authorize(OAuth slice 2: fail-closed checks on the OAuth path (disabled, suspended, must-change-password, flock scope, rate limit) #796).