Skip to content
Open
3 changes: 2 additions & 1 deletion docs/decisions/795-openiddict-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ fails if any captured event carries one.
(#364); its own tests cover a principal without the claim from any scheme
(`OAuthToken_ForcedThroughTheDefaultScheme_IsStillRejected`).

#796 replaces both walls with real checks and decides what an OAuth principal carries.
#796 replaced the second wall with the real checks and made the first per-endpoint; see
[`796-oauth-fail-closed.md`](796-oauth-fail-closed.md). The wall-2 test went with it.

## What this does NOT cover

Expand Down
93 changes: 93 additions & 0 deletions docs/decisions/796-oauth-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# OAuth tokens run the session chain, on opted-in endpoints only (#796)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md);
> this file is the rationale. The design it implements is in
> [`docs/plans/788-mcp-oauth/`](../plans/788-mcp-oauth/02-design.md).

**Status:** accepted
**Date:** 2026-10-08

## What happened

No incident. #795 issued OAuth reference tokens and kept them off business endpoints with
two walls: those endpoints authenticated session JWTs only, and the token carried `sub`
alone. #796 lifts the second wall on purpose so an endpoint can accept an OAuth token,
and puts every per-request check in place first. No business endpoint accepts one yet;
`/mcp` (#806) is the first planned caller.

## The rule

**One principal shape.** 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`, so
`TenantResolutionMiddleware`, `FlockScopeResolutionMiddleware`, `CredentialEpochMiddleware`
(disabled user, suspended farm, epoch) and `MustChangePasswordMiddleware` run unchanged.
Authenticating later, as an authorization-time scheme, would skip all four.

**One scheme per endpoint.** The default scheme is a policy scheme. An endpoint marked
through `AcceptOAuthTokens(scopes)` authenticates with OpenIddict validation and nothing
else; every other endpoint authenticates with the session JWT scheme and nothing else. The
choice reads endpoint metadata, never the token's shape, so neither handler ever sees the
other's token. The marker type is private to `OAuthEndpoints`, so the only way to accept
OAuth tokens is the extension, which also attaches the rate limit and the scope gate.

**Scopes subtract.** `AcceptOAuthTokens` adds an authorization policy requiring one of the
named scopes. The endpoint's own role policy still applies, so effective permission is
role ∩ scope by composition. No product scope is registered yet; #798 registers the two
from #788 and #806 maps tools to them.

**Role freshness is the epoch (option 1).** A role change bumps `CredentialEpoch`, so the
token is refused on its next request and the assistant must reconnect. Option 2 (reading
roles live) would need a second freshness mechanism and a new Access contract.
Must-change-password is enforced at issuance: the authorize endpoint sits behind
`MustChangePasswordMiddleware`, and every path that changes the flag afterwards bumps the
epoch.

**Disconnect revokes the authorization.** Authorize creates an ad-hoc authorization, and
every code and token carries its id. Validation calls `EnableAuthorizationEntryValidation()`,
so a revoked authorization refuses its access token on the next request. OpenIddict skips
that check for a token that names no authorization, so an inline handler refuses such a
token. That handler also makes a switch to self-contained access tokens fail closed: with
`UseLocalServer`, OpenIddict reads token entries, and so authorization ids, only for
reference tokens. A code from a revoked authorization no longer redeems (OpenIddict's server check),
and there is no refresh grant. `TryRevokeAsync` returns false on a concurrency failure, and
OpenIddict's tables carry no `AccountId`, so the #799 Disconnect action must check the
result and that the authorization's subject is the caller (or an Owner of that farm).

**Header-only tokens.** Validation ignores tokens in a query string (they reach request
logs) or a form body (they would dodge the per-token rate-limit key).

**Rate limits** use `DistributedFixedWindowPolicy` on the shared `IFixedWindowCounter`
(#543/#544), configurable under `RateLimiting:*`:

| Policy | Applied to | Key | Default |
|---|---|---|---|
| `oauth-token` | `POST /api/v1/oauth/token` | client IP | 20 / 60 s |
| `oauth-authorize` | `GET /api/v1/oauth/authorize` | client IP | 20 / 60 s |
| `oauth-api` | every `AcceptOAuthTokens` endpoint | SHA-256 of the bearer | 120 / 60 s |

OpenIddict also accepts a POSTed authorization request, which would match no endpoint and
so no policy or body cap. A server handler refuses any non-GET authorization request
before OpenIddict reads the body or looks the client up.

`UseRateLimiter` runs before authentication, so `oauth-api` keys on the raw bearer: the
exact slice OpenIddict extracts after `Bearer `, hashed so the store never holds a usable
token. With no refresh grant, a token is one connection. The token endpoint is mapped
through OpenIddict's passthrough so it can carry the policy and a body cap; it also
ignores any ambient session bearer, which would otherwise resolve a tenant and demand an
`Idempotency-Key`.

## What this does NOT cover

- **Junk bearers.** Each invalid token gets its own `oauth-api` bucket and costs one token
lookup. A per-IP ceiling beside the per-token key is not built.
- **Response shape for OAuth callers.** The chain's 401s carry no `WWW-Authenticate:
Bearer error="invalid_token"`, and a scope denial uses the role-denial body. #806 decides
what an MCP client needs.
- **Audit attribution** of the acting client (#788 plans first-class columns).

## How it is enforced

`OAuthFailClosedTests` maps two test-only probes into the real endpoint table and drives
each check with a token from the real flow. `tools/oauth/mutation-check.sh` proves each
test can fail; the mutants and their declared failures are listed in the script.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Starting a new record: copy [`TEMPLATE.md`](TEMPLATE.md).
| [Both JWT keys checked at boot, serving-only (#510)](510-jwt-key-boot-check.md) | AGENTS · Conventions |
| [Data Protection key ring in Postgres, encrypted in Production (#794)](794-data-protection-key-ring.md) | src/AGENTS · Boot guards |
| [OpenIddict outside Production only, on the shared Data Protection ring (#795)](795-openiddict-server.md) | src/AGENTS · Auth and credentials |
| [OAuth tokens run the session chain, on opted-in endpoints only (#796)](796-oauth-fail-closed.md) | src/AGENTS · Auth and credentials |
| [OAuth client self-registration and the OAuth purge sweep (#797)](797-oauth-client-registration.md) | src/AGENTS · Auth and credentials |
| [Nothing writes an audit event without an actor (#500)](500-audit-actor.md) | AGENTS · Conventions |
| [Break-glass recovery: `recover-admin` (#265)](265-break-glass-recovery.md) | AGENTS · Conventions · and the [runbook](../runbooks/break-glass-account-recovery.md) |
Expand Down
2 changes: 1 addition & 1 deletion docs/runbooks/simulation-fixture-on-a-dev-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ dotnet user-secrets --project src/Cluckwork.Api remove "RateLimiting:ClientError
section is bound once, at service registration, and the numbers are baked
into the policy objects right there
([`CluckworkRateLimitingServiceCollectionExtensions.cs`](../../src/Cluckwork.Api/Hosting/CluckworkRateLimitingServiceCollectionExtensions.cs)
— `Get<RateLimitingOptions>()`, then `new DistributedIpFixedWindowPolicy(…,
— `Get<RateLimitingOptions>()`, then `new DistributedFixedWindowPolicy(…,
rateLimiting.Login.PermitLimit, …)`). Nothing re-reads them, so a process
left running keeps serving the 1,000,000 limits off a user-secrets file
that no longer mentions them — the worst version of this, because the
Expand Down
3 changes: 2 additions & 1 deletion src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ record you must read before changing the rule. Persistence edits also read
- **Auth:** asymmetric JWTs with rotating refresh tokens. Config stores PEM keys with escaped `\n`; call `PemKey.Normalize` before `ImportFromPem`. Integration tests generate an ephemeral RSA pair at process startup (`TestJwtKeys` in `CluckworkWebApplicationFactory.cs`). Never commit real key material.
- **Both JWT keys are checked at boot, and the check is serving-only (#510/#347).** `AddCluckworkIdentity` requires both keys non-blank **and importable**; use `IsNullOrWhiteSpace`, never `??` (the shipped `appsettings.json` carries `""`, which `??` does not catch), and import at boot rather than inside the `AddJwtBearer` delegate (which makes a corrupt key a per-request 500 behind a green health check). → [`510-jwt-key-boot-check.md`](../docs/decisions/510-jwt-key-boot-check.md)
- **Credential epoch revocation (#364).** Every access/refresh token is bound to the user's `CredentialEpoch` and every password-reset path bumps it. Epoch `0` is permanently retired, a missing or malformed claim is **always** a mismatch, and `CredentialEpochMiddleware` does a fresh DB read per authenticated request through `ICredentialEpochVerifier` (#857) — the round trip *is* the fail-closed guarantee, so do not cache it. The mismatch holds whatever the database holds (#1031): `CK_AspNetUsers_CredentialEpoch` makes a stored epoch below 1 unwritable, and login, the verifier and refresh each refuse one anyway. Never repair a bad row by setting it to 1; that revives outstanding epoch-1 credentials. **Break it and a revoked credential keeps working.** → [`364-credential-epoch-revocation.md`](../docs/decisions/364-credential-epoch-revocation.md)
- **The OAuth server runs outside Production only, and its tokens reach no business endpoint yet (#795).** `AddCluckworkIdentity` registers OpenIddict only for a serving process outside Production with `OAuth:Issuer` set; Production registers no OpenIddict service and maps no OAuth endpoint, whatever its configuration, until consent with step-up (#798) exists. The issuer comes from configuration, never the request Host (#538). Codes and reference access tokens use the Data Protection format, so the shared ring (#794) is what lets one replica redeem another's code. OpenIddict demands a signing and an encryption key, but with that format only identity tokens use them, so they are ephemeral, `openid` is removed from the registered scopes (OpenIddict otherwise grants it implicitly) and the key-set endpoint is unmapped; never grant `openid` without a persistent key. Business endpoints authenticate with the session JWT scheme only, and an issued token carries `sub` alone: routed through the default scheme it fails tenant resolution (no `account_id`), then #364's epoch check (no `credential_epoch`). #796 decides what an OAuth principal carries. A code filter drops every `OpenIddict*` event below Warning, because its Information dumps carry the PKCE verifier; an override cannot replace it, since a more specific child override wins. `tools/oauth/mutation-check.sh` proves each claim's test can fail. → [`795-openiddict-server.md`](../docs/decisions/795-openiddict-server.md)
- **The OAuth server runs outside Production only (#795).** `AddCluckworkIdentity` registers OpenIddict only for a serving process outside Production with `OAuth:Issuer` set; Production registers no OpenIddict service and maps no OAuth endpoint, whatever its configuration, until consent with step-up (#798) exists. The issuer comes from configuration, never the request Host (#538). Codes and reference access tokens use the Data Protection format, so the shared ring (#794) is what lets one replica redeem another's code. OpenIddict demands a signing and an encryption key, but with that format only identity tokens use them, so they are ephemeral, `openid` is removed from the registered scopes (OpenIddict otherwise grants it implicitly) and the key-set endpoint is unmapped; never grant `openid` without a persistent key. A code filter drops every `OpenIddict*` event below Warning, because its Information dumps carry the PKCE verifier; an override cannot replace it, since a more specific child override wins. `tools/oauth/mutation-check.sh` proves each claim's test can fail. → [`795-openiddict-server.md`](../docs/decisions/795-openiddict-server.md)
- **An OAuth token runs the session chain, and only where an endpoint opts in (#796).** The authorize endpoint copies the session principal's `sub`, `email`, `account_id`, `credential_epoch`, `role` and `must_change_password` into the token, and OpenIddict validation authenticates it during `UseAuthentication`, so tenant, flock scope, epoch (disabled, suspended, role change) and must-change-password apply unchanged; never authenticate OAuth at authorization time, which skips all four. The default scheme routes by endpoint metadata, never token shape: `AcceptOAuthTokens(scopes)` endpoints accept OAuth tokens only, every other endpoint session JWTs only. That extension is the only way in (its marker is private): it attaches the `oauth-api` rate limit (shared counter, keyed per bearer hash) and a scope gate beside the role policy, so permission is role ∩ scope. No business endpoint opts in yet. Role freshness is the epoch: a demotion disconnects the assistant. Disconnect revokes the authorization; validation checks it on every request and refuses a token naming none, and there is no refresh grant. → [`796-oauth-fail-closed.md`](../docs/decisions/796-oauth-fail-closed.md)
- **OAuth clients register themselves, and dead OAuth rows are swept (#797).** `POST /api/v1/oauth/register` (RFC 7591) is anonymous on purpose: a registered client gets nothing until a user approves it (#798), so the risk is rows, and keep that reasoning beside the endpoint. It registers public clients with the authorization-code grant only; `refresh_token` is accepted and dropped, every other grant, response type and client authentication method is refused, and redirect URIs must be `https` or `http` on `localhost`, `127.0.0.1` or `[::1]`, with no fragment or user info; custom schemes are refused. A client whose redirect URIs are all `http` loopback is registered as native with port-less URIs, so it may authorize on any port (RFC 8252 §7.3) while scheme, host, path and query still match. The response returns the stored strings, and an OpenIddict validation refusal is `invalid_redirect_uri`, never a 500. The client's name is untrusted: controls and format characters, bidi among them, become spaces, and it is capped at 100 UTF-16 units on a grapheme boundary; no other client metadata is stored. The `oauth-register` policy limits it per client IP on the shared `IFixedWindowCounter`, never a process-local limiter. `OAuthPurgeSweep` runs under `DurableJobWorker`'s leader gate, never OpenIddict's Quartz job: it prunes dead tokens and authorizations older than 14 days with OpenIddict's `PruneAsync`, then deletes applications older than a day with no authorization and no token, in that order because the foreign keys do not cascade. Approval is an authorization row, and a live connection holds a valid, non-expiring access token, so it survives. The application's `CreatedAtUtc` is a shadow column OpenIddict lacks, stamped by #819's `StampCreatedBusinessRecord` trigger and configured with `BusinessRecordModel.ConfigureCreatedTimestamp`, and the window is measured with the database's `now()`. Both sit behind #795's Production gate, and `IOAuthPurge` is registered only with the server. Client ID Metadata Documents are deferred. → [`797-oauth-client-registration.md`](../docs/decisions/797-oauth-client-registration.md)
- **First-run admin: `bootstrap-admin` (#283).** Creates an Owner with a generated password (stdout only, never the logger/OTLP) and `MustChangePassword=true`, **only** if the default account has no Owner; a re-run is a silent no-op. While the flag is set, `MustChangePasswordMiddleware` 403s everything except `auth/change-password` and `auth/logout`. → [`283-first-run-admin-provisioning.md`](../docs/decisions/283-first-run-admin-provisioning.md)
- **Break-glass: `recover-admin` (#265).** Same run-then-exit shape as `seed`, but deliberately **not** environment-gated — it must work against a real Production database. One transaction: freshly generated temp password (never one passed on the command line), rotated security stamp, every refresh token revoked, and a `User.BreakGlassReset` audit row carrying `--reason`. → [`265-break-glass-recovery.md`](../docs/decisions/265-break-glass-recovery.md) · [runbook](../docs/runbooks/break-glass-account-recovery.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@
using System.Security.Cryptography;
using Cluckwork.Api.Configuration;
using Cluckwork.Api.Middleware;
using Cluckwork.Api.Modules.Access.OAuth;
using Cluckwork.Api.Security;
using Cluckwork.Application.Common;
using Cluckwork.Infrastructure.Persistence;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Validation.AspNetCore;

namespace Cluckwork.Api.Hosting;

internal static class CluckworkIdentityServiceCollectionExtensions
{
private const string BearerSelectorScheme = "Cluckwork.Bearer";

// role is OneShot for the operator verbs. Nothing here issues or validates a
// token for them — see the key guard below, which is serving-only for that
// reason and would otherwise be a fresh instance of the #331 class this file
Expand Down Expand Up @@ -171,7 +175,18 @@ public static CluckworkIdentityRegistration AddCluckworkIdentity(

var oauthIssuer = OAuthIssuer(configuration, environment, role);
if (oauthIssuer is not null)
{
services.AddAccessOAuthServer(oauthIssuer, allowPlainHttp: environment.IsDevelopment());
// #796 — one handler per endpoint: OpenIddict validation where the endpoint
// opted in through AcceptOAuthTokens, the session JWT scheme everywhere else.
// Neither token is ever handed to the other's handler.
services.AddAuthentication(options => options.DefaultScheme = BearerSelectorScheme)
.AddPolicyScheme(BearerSelectorScheme, displayName: null, options =>
options.ForwardDefaultSelector = context =>
OAuthEndpoints.AcceptsOAuthTokens(context.GetEndpoint())
? OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
: JwtBearerDefaults.AuthenticationScheme);
}

return new(OAuthServer: oauthIssuer is not null);
}
Expand Down
Loading
Loading