Repository navigation
docs(auth): record the OAuth 2.1 decision for MCP authentication - #801
Conversation
#788 asked how an MCP client could authenticate, given the access token lives 15 minutes and rotation happens through an HttpOnly cookie. The answer is to build an OAuth 2.1 authorization server with OpenIddict, alongside the existing login, serving third-party clients only. Records what a later reader cannot reconstruct: - The rejected alternative in full. A GitHub-style Personal Access Token design was worked through to completion and was cheaper by roughly half. It lost because a PAT is a workaround for MCP clients not being able to do OAuth, and OAuth is the better answer for delegated, revocable access to someone else's data. The design is kept, not discarded, in case OAuth proves too heavy. - Why OpenIddict: Microsoft documents that ASP.NET Core has no PAT or token-server primitive, so the options were OpenIddict or hand-rolling OAuth. Apache-2.0, net10.0, maintained. Duende was priced and rejected at $5,750-$24,900/yr. - Four accepted risks, including one-maintainer bus factor and two authentication paths coexisting. - The load-bearing constraint: an OAuth request does not pass through CredentialEpochMiddleware, so disabled-user, suspended-farm and must-change-password checks are NOT inherited and must be re-established. - Four verified findings that overturned earlier assumptions in the same conversation, including that TOTP is in-box rather than greenfield. Lands in docs/plans/ rather than docs/decisions/ because a decision record here is the relocated rationale for a rule that also appears in AGENTS.md, and nothing has shipped yet. The rule that will be owed, and who owes it, is named in the plan's README.
|
@coderabbitai review Docs-only, but the prose makes falsifiable claims about this repo and about third-party libraries. Please weight correctness over style — a wrong claim here misleads whoever implements six slices. Worth checking specifically:
No source changes. The PR deliberately does not close #788, which stays open as the parent of #795-#800. |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe PR adds design-only documentation for MCP OAuth 2.1 using OpenIddict. It records the decision, planned authorization flow, security requirements, library evaluation, verified framework findings, and implementation slices. No application code is added. ChangesMCP OAuth design
Priority: ➖ Normal Estimated code review effort: 1 (Trivial) | ~5 minutes Change: Other · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to This documentation-only change introduces no runtime or deployment risk and is ready to merge after normal checks. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Linked Issues checkExplanation The documents correctly record the selected OAuth 2.1 and OpenIddict design for ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '=== Relevant documentation files ==='
fd -t f '04-verified-findings.md|02-design.md|README.md' docs 2>/dev/null || true
printf '%s\n' '=== Document claims and context ==='
for f in $(fd -t f '04-verified-findings.md|02-design.md' docs 2>/dev/null); do
echo "--- $f"
rg -n -C 8 'AccountId|TenantStampInterceptor|TenantBypassDiscoveryTests|Data Protection|DataProtection|AddDataProtection|PersistKeys|AddDefaultTokenProviders|GeneratePasswordResetTokenAsync|passkey|Passkey|SignInManager|AddIdentityCore|AddSignInManager|scope|Scope|intersection|gate' "$f" || true
done
printf '%s\n' '=== Candidate source and test files ==='
fd -t f . | rg -i '(TenantStampInterceptor|TenantBypassDiscoveryTests|Program|ServiceCollection|Identity|Flock|Tenancy|OpenIddict|AccountId)'
printf '%s\n' '=== Identity, data-protection, and reset-token registrations/usages ==='
rg -n -i -C 5 'AddDataProtection\s*\(|PersistKeysTo|AddDefaultTokenProviders\s*\(|GeneratePasswordResetTokenAsync\s*\(|AddIdentityCore\s*(<|\()|AddSignInManager\s*(<|\()|SignInManager\s*<' -g '*.cs' -g '*.csproj' .
printf '%s\n' '=== Tenancy discovery/interceptor/flock patterns ==='
rg -n -i -C 8 'TenantStampInterceptor|TenantBypassDiscoveryTests|DiscoveredSurface_Floor|AccountId|flock' -g '*.cs' .
printf '%s\n' '=== Project package/framework versions relevant to Identity ==='
rg -n -C 3 'TargetFramework|Microsoft\.AspNetCore\.Identity|OpenIddict' -g '*.csproj' -g 'Directory.Packages.props' -g 'global.json' . || true
printf '%s\n' '=== Scope authorization implementation ==='
rg -n -i -C 8 'scope|Scope|require.*claim|RequireClaim|authorization|Authorization' -g '*.cs' . | head -n 800Length of output: 202074 🏁 Script executed: #!/bin/bash
set -euo pipefail
docs=$(fd -t f -a '04-verified-findings.md|02-design.md' docs)
printf '%s\n' '=== Documents ==='
printf '%s\n' "$docs"
printf '%s\n' '=== Complete relevant document sections ==='
while IFS= read -r f; do
echo "--- $f"
nl -ba "$f" | sed -n '/^ *[0-9]*.*§\? *2\|^ *[0-9]*.*Passkey\|^ *[0-9]*.*3\|^ *[0-9]*.*4\|^ *[0-9]*.*Data Protection\|scope/,+100p' | head -n 500
done <<< "$docs"
printf '%s\n' '=== Exact tenancy model configuration ==='
rg -n -l 'TenantStampInterceptor|HasQueryFilter|FindProperty\(.*AccountId|nameof\(.*AccountId|FlockScope' src/Cluckwork.Infrastructure tests/Cluckwork.Application.Tests/TenantBypass tests/Cluckwork.Application.Tests/FlockScope | while IFS= read -r f; do
echo "--- $f"
rg -n -C 12 'TenantStampInterceptor|HasQueryFilter|FindProperty\(.*AccountId|nameof\(.*AccountId|FlockScope|DiscoveredSurface_Floor|Assert\.Equal' "$f"
done
printf '%s\n' '=== Exact identity/data protection registrations and token call sites ==='
rg -n -l 'AddIdentityCore|AddSignInManager|AddDefaultTokenProviders|AddDataProtection|PersistKeysTo|GeneratePasswordResetTokenAsync' src tests | while IFS= read -r f; do
echo "--- $f"
rg -n -C 10 'AddIdentityCore|AddSignInManager|AddDefaultTokenProviders|AddDataProtection|PersistKeysTo|GeneratePasswordResetTokenAsync' "$f"
done
printf '%s\n' '=== Scope filter construction ==='
rg -n -l 'HasQueryFilter|IsUnrestricted|AssignedFlockIds' src/Cluckwork.Infrastructure | while IFS= read -r f; do
echo "--- $f"
rg -n -C 14 'HasQueryFilter|IsUnrestricted|AssignedFlockIds|AccountId' "$f"
done
printf '%s\n' '=== All DataProtection persistent-key APIs (source only) ==='
rg -n -i 'AddDataProtection|PersistKeysTo|DataProtectionProvider|IXmlRepository|KeyRing' src -g '*.cs' || trueLength of output: 133688 🤖 web_search executed:
Length of output: 12973 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '=== Exact asserted model/discovery surfaces ==='
rg -n -i -C 5 'GetEntityTypes\(\)|GetDeclaredQueryFilters\(\)|Assert\.Equal\(\s*\[|expected.*(entity|filter|surface|model)|IdentityUserClaim|IdentityUserLogin|IdentityUserToken|IdentityUserRole|ApplicationUser|ApplicationRole' tests -g '*.cs' | head -n 1800
printf '%s\n' '=== Identity service registration, including possible indirect registration ==='
rg -n -C 6 'AddIdentityCore|AddSignInManager|AddIdentity\(|SignInManager\s*<' . -g '*.cs' -g '*.csproj' -g '*.props' -g '*.targets'
printf '%s\n' '=== Data Protection API and reset-token calls across tracked files ==='
git grep -n -i -E 'AddDataProtection|PersistKeysTo|DataProtectionProvider|IXmlRepository|KeyRing|AddDefaultTokenProviders|GeneratePasswordResetTokenAsync' || true
printf '%s\n' '=== Relevant part of discovery test (all expected names) ==='
nl -ba tests/Cluckwork.Application.Tests/TenantBypass/TenantBypassDiscoveryTests.cs | sed -n '32,90p'
printf '%s\n' '=== Role authorization definitions and route/tool authorization attributes ==='
rg -n -C 7 'RequireRole|Roles\s*=|RequireClaim|RequireAssertion|AddPolicy|Authorize' src/Cluckwork.Api src/Cluckwork.Infrastructure -g '*.cs' | head -n 1600Length of output: 215667
I also confirmed that the document does not state that this PR closes ✅ Action performedReview finished.
|
Independent review of the design documents found 3 major and 2 minor errors. All verified against source before applying. - Roles are NOT read live. TenantResolutionMiddleware copies them from token claims and AuthPolicies.EffectiveRole reads IsInRole off the principal; nothing reloads from the database. An earlier round of this design asserted the opposite and used it to argue against tying credentials to CredentialEpoch. What actually keeps roles fresh is revocation: a role change bumps the epoch and the next request fails. Consequence: an OAuth token with role claims keeps stale authority after a demotion unless it carries credential_epoch or reloads roles. #796 must choose; both options are now recorded. - The "bypasses CredentialEpochMiddleware" framing was wrong in both directions. That middleware inspects any authenticated principal with no scheme exemption, so it runs for OAuth callers and rejects tokens lacking credential_epoch. Flock resolution runs before it and must-change-password after it, so these are separate owners rather than one boundary. Rewritten around each check's actual owner. - Reference tokens do not by themselves deliver instant revocation. OpenIddict validates token entries but not authorization-grant status by default, so revoking an authorization can leave its access tokens usable — which matters more here because lifetimes are indefinite. Also corrected the claim that JWTs cannot be revoked; they can, via token-entry validation. - Data Protection: the failure boundary was overstated. Keys persist by default in an environment-dependent location and survive across requests in one process; the real risks are container replacement and a second instance without a shared ring. - DCR is not the MCP spec's recommendation. The 2025-11-25 spec recommends Client ID Metadata Documents and describes DCR as optional. Kept as a deliberate compatibility choice, now labelled as one.
🤖 I have created a release *beep* *boop* --- ## [0.1.2](v0.1.1...v0.1.2) (2026-09-16) ### Features * **data:** standardize business record chronology ([#820](#820)) ([6231b31](6231b31)) * **infra:** optional leader-lease endpoint for pooled deploys ([#869](#869)) ([e9bc6a7](e9bc6a7)) * **sim:** seed a second farm for the README dashboard capture ([#867](#867)) ([de407c6](de407c6)) * **web:** adopt MUI, themed from the farm palette tokens ([#674](#674)) ([#860](#860)) ([6c83c5c](6c83c5c)) * **web:** convert Daily entry to MUI, field-first on the phone ([#888](#888)) ([b66f8b8](b66f8b8)) * **web:** convert the Dashboard and app shell to MUI ([#829](#829)) ([#883](#883)) ([2e94277](2e94277)) * **web:** retire the Slack-blue link colour for ink + a rule underline ([#884](#884)) ([c08f9d8](c08f9d8)) * **web:** serve a per-request CSP nonce so Emotion's styles apply under style-src 'self' ([#874](#874)) ([ba4e6f3](ba4e6f3)) * **web:** visual language theme overrides for the MUI revamp ([#864](#864)) ([#882](#882)) ([0bb6b73](0bb6b73)) * **web:** whole-app MUI baseline, theme policy guard and the [#740](#740) phone action rule ([#823](#823)) ([#871](#871)) ([af565e4](af565e4)) ### Bug fixes * **auth:** fail closed on unresolved flock-scope actors ([#787](#787)) ([#868](#868)) ([16d0350](16d0350)) * **auth:** make farm configuration owner-only ([#870](#870)) ([42f9036](42f9036)) * **e2e:** repoint the canary at the markup two PRs replaced ([#844](#844)) ([18b45dc](18b45dc)) * **i18n:** tl glossary uses the standard passive of ilagay ([#813](#813)) ([20dec10](20dec10)), closes [#738](#738) * **sim:** stop the k6-baseline EXIT trap masking a clean run as failed ([#838](#838)) ([f5ec96f](f5ec96f)) * **web:** declare the rule tokens the Dashboard reads, and guard undeclared custom properties ([#885](#885)) ([5bead1f](5bead1f)) ### Performance * **ci:** start the serialized integration collection first ([#861](#861)) ([1dcc7f6](1dcc7f6)), closes [#839](#839) ### Documentation * **auth:** record the OAuth 2.1 decision for MCP authentication ([#801](#801)) ([0510854](0510854)) * **designs:** MUI revamp design doc, component map, layout system, IA ([#862](#862)) ([da49481](da49481)) * **readme:** recapture the daily entry, reports and sales screenshots ([#865](#865)) ([f18e336](f18e336)) * **specs:** correct the sales_order_items column list in §10.5 ([#812](#812)) ([afe4a02](afe4a02)), closes [#737](#737) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: cluckwork-lockfix[bot] <309265648+cluckwork-lockfix[bot]@users.noreply.github.com>
Records the design for MCP authentication. Design only — no code, nothing shipped.
#788 began as a question: an MCP client could not authenticate at all, because the access token lives 15 minutes and rotation happens through an HttpOnly refresh cookie, which is a browser mechanism. The answer is to build an OAuth 2.1 authorization server with OpenIddict, running alongside the existing login and serving third-party clients only.
Why this is worth a document rather than just issues
Three things a reader cannot reconstruct from the code that eventually lands:
The rejected alternative, in full. A GitHub-style Personal Access Token design was worked through to completion — self-service on the Account page,
cw_pat_prefix with CRC32 checksum for secret scanning, GitHub-matching revocation semantics, Owner-wide visibility. It was workable and cheaper by roughly half. It lost on one argument: a PAT is a workaround for MCP clients not being able to do OAuth, and OAuth is the better answer for delegated, revocable access to someone else's data. The design is kept rather than discarded, so if OAuth proves too heavy nobody rediscovers it.Why OpenIddict, and what it cost to rule out the others. Microsoft documents that ASP.NET Core has no PAT or token-server primitive — the Identity API's bearer tokens are "not intended to be a full-featured identity service provider or token server". So the options were OpenIddict or hand-rolling OAuth. Duende was priced ($5,750–$24,900/yr) and rejected.
Four accepted risks, stated rather than glossed — including one-maintainer bus factor on a security-critical dependency, and two authentication paths coexisting.
The constraint that matters most
An OAuth-authenticated request does not pass through
CredentialEpochMiddleware, which is where the app does its per-request fail-closed checks. So disabled user, suspended farm, must-change-password and flock scoping are not inherited — every one must be re-established on the OAuth path. That is the single most likely thing to be under-scoped, because it produces nothing visible, and it is why #796 exists as its own slice.Findings that overturned earlier assumptions
04-verified-findings.mdrecords four corrections made during the design, each verified against source or the restored assemblies rather than recalled:AuthenticatorTokenProvider, key generation and recovery codes all ship in Identity 10. An earlier round of this design advised against it on the opposite assumption.TenantBypassDiscoveryTests.DiscoveredSurface_Floorasserts exact set equality, so it must be edited deliberately. The rest of the tenancy stack skips entities withoutAccountIdstructurally, so the boot is unaffected.Why
docs/plans/rather thandocs/decisions/A decision record here is defined as "the relocated rationale for a rule that also appears — in one compressed paragraph — in
AGENTS.md". Nothing has shipped, so there is no rule to compress; filing there would mean writing anAGENTS.mdbullet for code that does not exist.docs/plans/is explicitly the paper trail of a feature's design captured before the work.A decision record is owed when the implementation lands, and the plan's README names both the rule it should carry and who owes it (#796).
Verification
Docs-only.
TenancyDocsFreshnessTestspasses; checked against both tracked-file guards (SchemaDocsTests' postgres pin, the tenancy staleness sweep) before committing, since #508 records a plan document tripping one of those.Issue linkage — deliberately none
This PR uses no closing keyword. Issue #788 is the parent of six implementation slices (#795–#800) and must stay open until they land.
Worth recording the near-miss, because it is precisely the failure this repo's convention warns about. The first draft of this body wrote the close-keyword followed by the word "no" and a sentence explaining it did not apply. GitHub parses the keyword and ignores the prose disclaiming it — so #788 would have auto-closed on merge, taking the parent of six open slices with it. A second draft then quoted that phrase while explaining the mistake, and re-armed it.
Caught both times by checking
closingIssuesReferencesthrough the GraphQL API rather than re-reading the body, which is exactly why the convention says to verify against the API and never by reading.Summary by CodeRabbit