Skip to content

feat(api): stand up OpenIddict as an OAuth 2.1 authorization server - #1135

Merged
mforce merged 10 commits into
mainfrom
feat/795-openiddict
Oct 8, 2026
Merged

mforce merged 10 commits into
mainfrom
feat/795-openiddict

Conversation

@mforce

@mforce mforce commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

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. AddCluckworkIdentity registers OpenIddict only for a serving process outside Production that has OAuth:Issuer set. 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:

  1. Business endpoints authenticate with the session JWT scheme only. A reference token is not a JWT, so it fails authentication before any middleware reads a claim.
  2. The token carries sub and nothing else. Forced through the default scheme, it has no account_id, so TenantResolutionMiddleware returns 401. If it did carry an account_id, the missing credential_epoch would read as epoch 0, which CredentialEpochMiddleware never accepts (Credential epoch: per-request revocation check, deployed inert ahead of the user-admin mutations #364).

This does rely on CredentialEpochMiddleware rejecting a principal with no credential_epoch, but only as the second line behind tenant resolution. The account-id-in-token mutant below shows that line holding.

Decisions the maintainer should know about

Guards this slice had to satisfy

  • TableOwnerOverrides gains four OpenIddict* rows owned by Access.
  • TenantBypassDiscoveryTests.DiscoveredSurface_Floor names the four entities with a stated reason. The test still checks exact set equality.
  • PlatformBusinessRecords excludes the four types. The Standardize business-record timestamps and chronological list ordering #819 census refused to build the model until it did.
  • RealModuleLedger.Adapters needs no row, because the endpoint reaches no module. AdapterReachRealTreeTests agrees.
  • CW1004 reports nothing. The endpoint reads ICurrentUser only, so IAccessModule needed no new operation.
  • One migration, AddOpenIddictTables, adds the tables, and docs/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

  • Start with src/Cluckwork.Infrastructure/Modules/Access/OAuth/OAuthServerRegistration.cs and the gate in CluckworkIdentityServiceCollectionExtensions.OAuthIssuer.
  • tests/Cluckwork.Api.IntegrationTests/OAuthServerTests.cs is the "done when" script.
  • The rationale is in docs/decisions/795-openiddict-server.md, with a new rule in src/AGENTS.md.

Proof

Targeted tests, run locally. I ran filtered tests only, never the full suite. CI is the authority.

  • Cluckwork.Application.Tests, filtered to Architecture|TenantBypass|BusinessRecord|TableOwner|Persistence: 447 of 447 passed. Before the ledger rows existed, exactly three of them failed: TableOwnerRealModelTests, DiscoveredSurface_Floor, and CouplingMatrixRealTreeTests, which follows from the first.
  • Cluckwork.Application.Tests, Documentation: 20 of 20.
  • Cluckwork.Api.IntegrationTests, the two new classes at 803499d5: 10 of 10.
  • Cluckwork.Api.IntegrationTests, 28 neighbouring classes at c6fce15a (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 on main.

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 reads Completed with 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. At 803499d5 the run had a 10/10 baseline, a 10/10 restore and 0 failures.

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 IssuesAReferenceTokenTheResourceSideAccepts the access token has no ReferenceId killed
default-token-lifetime access tokens live until revoked IssuesAReferenceTokenTheResourceSideAccepts 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 ever issued OpenIdScope_IsRefused Expected: BadRequest killed
verifier-logged no protocol secret reaches a log sink ProtocolSecrets_NeverReachTheLog protocol secrets reached the log killed (4 events)
parent-override-only the floor holds against a child override (the round-1 fix put back) ProtocolSecrets_NeverReachTheLog protocol secrets reached the log killed (4 events)
oauth-on-default-scheme wall 1, business endpoints reject the token at authentication OAuthToken_IsRejectedByBusinessEndpoints_AtAuthentication Assert.Contains() Failure: Filter not matched killed
session-claims-in-token wall 2, copying account_id and credential_epoch into the token OAuthToken_ForcedThroughTheDefaultScheme_IsStillRejected Expected: Unauthorized killed
account-id-in-token copying account_id only same none, it must pass held, because CredentialEpochMiddleware still refuses epoch 0

A 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 code also matches an unrelated HTTP 500 from the second host, and Expected: BadRequest accepts 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 the fbffc4e7 run 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_NeverReachTheLog configures Serilog to let OpenIddict log everything. It sets Default, Override:OpenIddict and the child Override:OpenIddict.Server to Verbose. It adds a DI ILogEventSink. 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 with protocol 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-admin and 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.

== discovery
{ "issuer": "http://localhost:8080/",
  "authorization_endpoint": "http://localhost:58795/api/v1/oauth/authorize",
  "token_endpoint": "http://localhost:58795/api/v1/oauth/token",
  "jwks_uri": null, "scopes_supported": null,
  "code_challenge_methods_supported": ["plain", "S256"] }
== authorize redirected to: http://127.0.0.1:9/callback?code=<redacted>&...
== token response (access_token redacted)
{ "access_token": "43 chars", "token_type": "Bearer" }
== code replayed: {"error":"invalid_grant"}
== stored token row
urn:ietf:params:oauth:token-type:access_token|revoked|t|
== GET /api/v1/me with the session JWT: 200
== GET /api/v1/me with the OAuth token: 401 WWW-Authenticate: Bearer error="invalid_token"

This run was at c6fce15a, before the S256-only commit, which is why plain still appears. A rerun from an export of fbffc4e7 found the verifier in 0 lines of the API's real Console output, and the code_verifier key in 0 lines. The remaining lines that mention OpenIddict are EF SQL commands on its tables, with every parameter rendered as '?'. Replaying the code got invalid_grant, and OpenIddict revoked the access token issued from that code. That is why the stored row reads revoked. 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. At d3ed85ce and fbffc4e7, CI, E2E smoke, CodeQL and backend coverage all passed. At 803499d5, all four passed again.

Not done here, for the coordinator's batch

mforce added 7 commits October 8, 2026 03:46
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
@mforce

mforce commented Oct 8, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 came from Codex GPT-6 Astra, at d3ed85ce. Both P2 findings are fixed.

P2: PKCE verifiers reached the application log. Fixed in 85f8fe1d.

  • I walked every OpenIddict 7.7.1 log call in the server, validation, ASP.NET Core, Data Protection, core and EF packages. Each request and response dump is at Information: authorization (6030), token (6075), revocation (6109) and introspection (6096) requests, the JSON, challenge and plain-text responses (6141 to 6143), and the redirect responses (6147 to 6149). OpenIddictMessage.ToString() redacts codes, tokens, client secrets, passwords and assertions. Of the dumps this server can emit, only the token request carries an unredacted secret, the code_verifier. state also appears unredacted, but it already travels in the redirect URL. At Warning and above, OpenIddict logs only row ids, key type names and exceptions.
  • AddCluckworkTelemetry now sets the OpenIddict categories to Warning after it reads the Serilog configuration, so no setting can let the dumps through. This covers every field a future OpenIddict version leaves unredacted. The base Console entry and the compact JSON formatter are unchanged (obs(api): emit compact JSON to stdout in Production so logs are machine-parseable (#273 prerequisite) #404).
  • ProtocolSecrets_NeverReachTheLog sets OpenIddict to Verbose through configuration. It adds a DI ILogEventSink, which receives the same filtered events as the Console sink. It runs a redeemed exchange with a marker verifier and a refused one with a marker verifier and a marker code. It fails if a captured event's message, properties or exception contains either verifier, the marker code, the real code or the access token. Removing the clamp turns it red with protocol secrets reached the log in 4 event(s).
  • A live rerun from an export of fbffc4e7 found the verifier in 0 lines of the API's Console output.

P2: the mutation runner counted infrastructure failures as kills. Fixed in 60ba3d22, with its baseline floor corrected in f881ce99.

  • Each mutant now runs only its named test, with a TRX logger. A kill counts only if that test failed and its message carries the mutant's declared text. A missing TRX, an aborted run, a test that did not run, or a failure on an exception instead of an assertion is INCONCLUSIVE and fails the run, following tools/simulation/ui/mutation-check.sh. The baseline and restore runs must pass at least 10 tests.
  • I tested the classifier on synthetic TRX variants of a real result. It returned INCONCLUSIVE for an aborted run, a missing test, a NotExecuted test, a missing TRX and an exception failure, and WRONG for a different assertion.
  • The first run of the new runner failed closed on my own floor of 11, because the suite has 10 tests.
  • At fbffc4e7 the rerun killed all 10 kill mutants on their declared text, including the new verifier-logged mutant. account-id-in-token held. Baseline and restore were 10/10, with 0 failures. The PR body has the table.

fbffc4e7 is the deslop pass. CI, E2E smoke, CodeQL and backend coverage all passed at fbffc4e7.

@mforce

mforce commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner Author

Review round 2 came from Codex GPT-6 Astra, at fbffc4e7. It found three P2 findings. Two are fixed and one is recorded as a known limit.

P2: a more specific override still enabled the verifier dump. Fixed in 9e8884b9, with comments tightened in 803499d5.

  • The round-1 parent override lost to any configured child override, because Serilog applies the most specific one. AddCluckworkTelemetry now adds a Serilog filter instead. It drops every event below Warning whose source is OpenIddict or a category under it, before any sink. No override or configuration reload changes it.
  • ProtocolSecrets_NeverReachTheLog now also sets the child Serilog:MinimumLevel:Override:OpenIddict.Server to Verbose.
  • The new parent-override-only mutant puts the round-1 parent override back in place of the filter. It turns the test red with protocol secrets reached the log in 4 event(s). Removing the filter entirely (verifier-logged) fails the same way.
  • The decision record and src/AGENTS.md now describe the filter and why an override cannot hold the floor.

P2: baseline and restore ignored run-level failure. Fixed in eb1606d2.

  • Baseline and restore now require three things. The test process must exit 0. The TRX run summary must read Completed with no run-level errors. At least 10 tests must have all run and passed.
  • I tested the gate on variants of a real restore TRX. It rejected a nonzero exit, a Failed, Error or Aborted summary, a run-level error with clean counters, and a run that executed 9 of 10 tests. It accepted the clean run.

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, Expected: BadRequest accepts any actual status, and the classifier checks the declared text before it checks for an exception.

Verification. The rerun at 803499d5 killed all 11 kill mutants on their declared text, and account-id-in-token held. Baseline and restore were each 10/10 under the stricter gate, with 0 failures. The PR body has the table. CI, E2E smoke, CodeQL and backend coverage all passed at 803499d5.

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.

@mforce
mforce merged commit 4961d00 into main Oct 8, 2026
20 checks passed
@mforce
mforce deleted the feat/795-openiddict branch October 8, 2026 05:47
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 1: stand up OpenIddict — tables, migration, token issuance

1 participant