feat(api): OAuth nonce replay-protection + state negative coverage#339
Conversation
#243) - /oauth/authorize accepts an optional nonce, bound to the authorization code and echoed in the /oauth/token response (OIDC-style replay protection). - Require a non-empty state at /oauth/authorize, /oauth/verify-code and /oauth/google (invalid_request) so clients cannot skip CSRF protection. - Harden OAuthLoginPage: inject OAuth params as JSON-serialized (HTML-safe) values instead of HtmlEncode inside a JS-string context, fixing a latent script-string injection and safely carrying the new nonce. - Add negative state tests (missing / verbatim-echo / query-injection) and nonce binding tests (store round-trip + end-to-end through verify-code and google into the token echo). Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review: PR #339 — feat(api): OAuth nonce replay-protection + state negative coverage
Scope: PR #339 in thomasluizon/orbit-api
Recommendation: APPROVE
Summary
Hardens the /oauth/* authorization-code flow used by the Claude.ai MCP client: rejects a missing/empty state server-side on /oauth/authorize, /oauth/verify-code, and /oauth/google; threads an optional client-supplied nonce from /oauth/authorize through the login page into the token exchange; and fixes a latent script-string-injection bug in OAuthLoginPage by switching from WebUtility.HtmlEncode to JsonSerializer.Serialize for values embedded in the inline <script> block. Six files changed (3 source, 1 generated doc, 2 test files), with solid new unit-test coverage. No Critical or High findings survived review.
Findings
Critical
None.
High
None.
Medium
[MEDIUM] openapi.json silently documents the wrong request body for /oauth/verify-code and /oauth/google
· dimension: Backend hard rules / OpenAPI doc accuracy (Orbit.Api/CLAUDE.md "OpenAPI / Scalar")
· location: orbit-api/src/Orbit.Api/openapi.json:14404-14428 (VerifyCodeRequest schema) and :13273-13305 (GoogleAuthRequest schema); colliding types at orbit-api/src/Orbit.Api/Controllers/OAuthController.cs:156-159,187-190 vs orbit-api/src/Orbit.Api/Controllers/AuthController.cs:19-20
· issue: OAuthController.VerifyCodeRequest/GoogleAuthRequest and AuthController.VerifyCodeRequest/GoogleAuthRequest are distinct C# record types sharing the same simple name. Microsoft.AspNetCore.OpenApi keys components.schemas by short type name, so /api/Auth/verify-code and /oauth/verify-code (same for /oauth/google) both $ref the same schema entry — which only reflects AuthController's shape (email/code/language/referralCode). Confirmed by grepping the whole file for codeChallenge: zero hits, even though CodeChallenge is a property on both OAuthController request records. This pre-dates the PR, but the PR's own new Nonce field (and the rest of State/CodeChallenge/RedirectUri/ClientId) is consequently invisible in the generated docs, contradicting the PR description's claim that "openapi.json is the build-generated surface doc, updated in sync with the new nonce param."
· risk: anyone consulting Scalar/the OpenAPI doc for /oauth/verify-code or /oauth/google — including engineers integrating the Claude.ai MCP OAuth client, this PR's own stated audience — sees a request-body schema with none of the real OAuth fields.
· fix: disambiguate the colliding record names (e.g. OAuthVerifyCodeRequest/OAuthGoogleAuthRequest) or configure a custom schema-ID resolver (fully-qualified type name) in the OpenApi options so each endpoint gets its own component.
· reference: Orbit.Api/CLAUDE.md "OpenAPI / Scalar"; pre-existing, not introduced by this diff, not blocking — safe to land as a tracked follow-up.
Low / Info
- The
nonceecho at/oauth/tokenis never verified server-side against anything (OAuthController.cs:334-335) — it is bound to the code and returned as-is, so the actual replay check happens client-side (the client compares the nonce it sent at/authorizeto what comes back), exactly mirroring how OIDC'snonceclaim works. Given/oauth/tokenalready requires PKCE S256 (code_verifierbound tocode_challenge), the marginal protection nonce adds on top of PKCE is small — worth being precise about that in future docs, but not a defect. - The new explicit
statenull/empty checks inAuthorize/VerifyCode/GoogleAuthmay be redundant with ASP.NET Core's implicit-required validation for non-nullable[FromQuery] string stateon an[ApiController]action — but this mirrors the pre-existing pattern already used forclient_id/redirect_uri/response_type/code_challengein the same controller, and it's what lets the new unit tests exercise the check directly (they call the action method, bypassing the MVC pipeline). Consistent with existing style, not a new anti-pattern.
Subagents
| Agent | Verdict |
|---|---|
| security-reviewer | PASS — no injection/authz/data-exposure/crypto issues; confirmed the JsonSerializer.Serialize-into-<script> fix is sound against script-context breakout (default JavaScriptEncoder escapes < > & ' and backslashes); confirmed nonce is not cryptographically enforced server-side (informational only) |
| contract-aligner | NOT VERIFIABLE — orbit-ui-mobile is not checked out in this review environment; local grep in orbit-api found no evidence /oauth/* is consumed by packages/shared or the mobile/web apps (matches the PR's own claim of MCP-client-only scope) |
Validation
| Check | Result |
|---|---|
| Build (dotnet) | N/A — this review session could not check out the PR branch (git fetch / gh pr checkout / gh api are blocked by this sandbox's permission layer) or write local files to apply the diff; left to the repo's required "API Tests" CI workflow |
| Tests (dotnet) | N/A — same reason |
Deferred — N/A dimensions & files not verdicted
- Parity (#9) / i18n (#10): N/A — frontend-only dimensions; no
apps/*files touched. - DESIGN.md / AI-slop (#8): N/A — no
apps/*UI files touched (the OAuth login page is server-rendered HTML in orbit-api, outside this dimension's gate). - FEATURES.md parity (#14): N/A — this diff hardens the transport security of an existing OAuth/MCP integration; it adds no screen, tool, or gating change that would make FEATURES.md stale.
- Contract drift (#11): NOT VERIFIABLE here — sibling repo absent from this checkout (see contract-aligner above).
- Validation (Phase 7): N/A — PR branch not checked out / no local file writes available in this sandboxed run; rely on CI's required Build/Unit Tests checks instead.
All 6 changed files (OAuthController.cs, OAuthAuthorizationStore.cs, OAuthLoginPage.cs, openapi.json, OAuthControllerTests.cs, OAuthAuthorizationStoreTests.cs) received a verdict above; nothing changed was silently skipped.
What's good
- The
WebUtility.HtmlEncode→JsonSerializer.Serializeswitch is a real, correctly-implemented fix for a genuine latent injection bug (HtmlEncode doesn't escape backslashes, the wrong encoding for a JS-string sink); independently verified sound by the security-reviewer subagent. - Missing-
staterejection closes a real CSRF gap (state was previously accepted even when absent). - Strong, focused unit-test coverage for every new branch: missing-state on all three endpoints, state echoed verbatim, state-with-query-injection percent-encoding, nonce round-trip through both the code and Google-auth paths, nonce present/absent in the token response. New
GeneratePkce/ExtractRedirectUrl/ExtractQueryParamtest helpers are a reasonable extraction (used 4+ times across the new tests). - No narration comments, no dead code, no type-safety escape hatches introduced.
Recommendation
Safe to merge. Non-blocking follow-up: fix the VerifyCodeRequest/GoogleAuthRequest OpenAPI schema-name collision (Medium finding above) so /oauth/verify-code and /oauth/google document their actual request bodies.
There was a problem hiding this comment.
Code Review: PR #339 — feat(api): OAuth nonce replay-protection + state negative coverage
Scope: PR #339 in thomasluizon/orbit-api (head 0367f4b)
Recommendation: APPROVE
Summary
Re-review at the current head. Since my prior APPROVE (submitted against 1a13c5e), the only new commit is 0367f4b — a merge of main bringing in unrelated Sentry PII-scrubbing fixes (#243/#331/#337). Confirmed via git diff 1a13c5e 0367f4b -- <OAuth files> that the merge touched none of the OAuth files (AuthController.cs, SentryEventScrubber.cs, email/marketing services, and their tests are the only files it moved) — it's a clean no-op for this feature. The OAuth diff itself is unchanged: still hardens the /oauth/* authorization-code flow used by the Claude.ai MCP client — rejects a missing/empty state server-side on /oauth/authorize, /oauth/verify-code, and /oauth/google; threads an optional client-supplied nonce from /oauth/authorize through the login page into the token exchange; and fixes a latent script-string-injection bug in OAuthLoginPage by switching from WebUtility.HtmlEncode to JsonSerializer.Serialize for values embedded in the inline <script> block.
Independently re-verified this run:
- security-reviewer subagent: PASS.
JsonSerializer.Serializewith default options usesJavaScriptEncoder.Default, safe against</script>breakout for all 7 interpolated values.statefail-closed ordering is correct in all three handlers (checked before any state-changing action — mediator dispatch, Google token-info call, code creation). Rate limiting,[AllowAnonymous]scope, and logging are all unchanged and clean. One Low/optional note:noncehas no explicit max-length validator, but this matches the pre-existing pattern forcodeChallenge/clientIdand is bounded by the 5-minute code TTL — not a new risk introduced by this diff, not blocking. - contract-aligner subagent: MATCH (no drift).
orbit-ui-mobileisn't checked out in this CI job so the Zod-schema side is formally NOT_VERIFIABLE, but an internal grep found/oauth/*referenced nowhere outsideOAuthController.cs/OAuthLoginPage.cs— consistent with the PR's own claim that this is MCP-client-only surface, not part of thepackages/sharedREST contract. Thestate-required tightening has no evident existing caller that omitsstate(the only caller is the login page's own script, which always forwards the value sourced from the upstream/authorizecall).
No Critical or High findings. My own correctness/dead-code/comment-policy/backend-hard-rules pass over the diff found nothing new — strong test coverage (missing-state on all three endpoints, state echoed verbatim, state-with-query-injection percent-encoding, nonce round-trip through both code and Google-auth paths, nonce present/absent in the token response).
Findings
Critical
None.
High
None.
Medium
[MEDIUM] openapi.json documents the wrong request-body schema for /oauth/verify-code and /oauth/google (pre-existing, not introduced by this diff)
· dimension: Backend hard rules / OpenAPI doc accuracy
· location: src/Orbit.Api/openapi.json (VerifyCodeRequest/GoogleAuthRequest schema entries); colliding types at src/Orbit.Api/Controllers/OAuthController.cs:156-159,187-190 vs src/Orbit.Api/Controllers/AuthController.cs
· issue: OAuthController.VerifyCodeRequest/GoogleAuthRequest and AuthController.VerifyCodeRequest/GoogleAuthRequest are distinct record types sharing the same simple name. Microsoft.AspNetCore.OpenApi keys components.schemas by short type name, so both controllers' endpoints $ref the same schema entry, which only reflects AuthController's shape. This PR's new Nonce field (and State/CodeChallenge/RedirectUri/ClientId) stays invisible in the generated docs.
· risk: anyone consulting the OpenAPI doc for /oauth/verify-code or /oauth/google — including engineers integrating the Claude.ai MCP OAuth client — sees a schema with none of the real OAuth fields.
· fix: disambiguate the record names (e.g. OAuthVerifyCodeRequest/OAuthGoogleAuthRequest) or configure a custom schema-ID resolver (fully-qualified type name).
· reference: pre-existing collision, not caused by this PR — safe to land as a tracked follow-up, not blocking.
Low / Info
nonceis bound to the code and echoed as-is at/oauth/token, never verified server-side — the actual replay check happens client-side, which mirrors how OIDC'snonceclaim is meant to work. Given PKCE S256 already bindscode_verifier, the marginal protection nonce adds is small but real (informational, not a defect).
Subagents
| Agent | Verdict |
|---|---|
| security-reviewer | PASS — no injection/authz/data-exposure/crypto issues |
| contract-aligner | MATCH — no drift found; sibling repo not checked out, Zod side formally NOT_VERIFIABLE |
What's good
- The
WebUtility.HtmlEncode→JsonSerializer.Serializeswitch is a real, correctly-implemented fix for a genuine latent script-context injection bug. - Missing-
staterejection closes a real CSRF gap (state was previously accepted even when absent), fail-closed in all three handlers. - Strong, focused unit-test coverage for every new branch.
- No narration comments, no dead code, no type-safety escape hatches introduced.
Recommendation
Safe to merge. Non-blocking follow-up: fix the VerifyCodeRequest/GoogleAuthRequest OpenAPI schema-name collision (Medium finding above, pre-existing).
There was a problem hiding this comment.
PR Review — #339 (OAuth nonce replay-protection + state negative coverage)
Recommendation: APPROVE
Summary
This is a re-review focused on the diff since my last approval. I compared the current head (9c29e5b) against the commit from my second review (0367f4b) and found the only changes are two more Merge branch 'main' commits pulling in already-merged, unrelated main commits (NotificationTools.cs, SkipHabitCommandValidator.cs, HandleWebhookCommand.cs, AgentAuditRedactor.cs, PushNotificationService.cs, and their tests).
git diff scoped to this PR's actual files (OAuthController.cs, OAuthAuthorizationStore.cs, OAuthLoginPage.cs, openapi.json, OAuthControllerTests.cs, OAuthAuthorizationStoreTests.cs) between the original feature commit and the current head is empty — zero bytes changed in the reviewed surface.
Findings
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 0 |
| Medium | 1 (carried over, pre-existing, non-blocking) |
| Low / Info | 2 (carried over) |
- Medium (pre-existing, non-blocking):
openapi.jsonhas a schema-name collision betweenOAuthController.VerifyCodeRequest/GoogleAuthRequestandAuthController's same-named records, which can cause incorrect OpenAPI doc generation. Unrelated to this PR's diff; flagged as a follow-up in prior reviews.
Notes
- Security review and cross-repo contract check were not re-run against unchanged bytes (already evaluated PASS / MATCH-or-N/A in prior reviews of this exact code).
- Cross-repo dimensions (contract-aligner, mobile-side parity) remain not verifiable in this CI job —
orbit-ui-mobileisn't checked out here. Consistent with prior conclusion that/oauth/*is MCP-client-only surface, not part of the mobile/web contract. - Build/test skipped per this job's CI adaptation — handled by required CI checks (Build / Unit Tests / SonarCloud).
No action needed — safe to merge.
|
There was a problem hiding this comment.
Code Review: PR #339 — feat(api): OAuth nonce replay-protection + state negative coverage
Recommendation: APPROVE
Summary
This is the fourth review pass on this PR. Comparing the current head (41ce2af4) against the commit from the prior (third) review (9c29e5b2), scoped to the PR's own files (OAuthController.cs, OAuthAuthorizationStore.cs, OAuthLoginPage.cs, openapi.json, and the two test files), the diff is empty. The only commits added since were two more Merge branch 'main' merges pulling in unrelated main-branch work (#338 User.Handle uniqueness index, rate-limit test additions, GooglePlayPushTokenValidator changes). The full OAuth diff was independently re-read, plus a fresh pass from the security-reviewer subagent against current file contents.
The feature hardens /oauth/* (external Claude.ai MCP client flow only): (a) an optional OIDC-style nonce bound server-side to the single-use authorization code and echoed at token exchange, and (b) mandatory state (CSRF) on all three entry points, rejected before any expensive/external work. It also fixes a real (if latent) script-string-injection bug in OAuthLoginPage.cs by switching from WebUtility.HtmlEncode (wrong encoding for a JS-string sink — doesn't escape \) to JsonSerializer.Serialize embedded as a JS object literal (default encoder escapes < > & ' ", neutralizing </script> breakout).
Findings
Critical
None.
High
None.
Medium
- Pre-existing, non-blocking, carried over from all three prior reviews:
src/Orbit.Api/openapi.jsonhas a schema-name collision —OAuthController.VerifyCodeRequest/GoogleAuthRequestshare names withAuthController's unrelated same-named records, and the generator resolves both$refs to theAuthControllershape (accessToken,googleAccessToken, etc. — confirmed at lines ~13273 and ~14404). This means the newNoncefield never shows up in the OAuth request-body schemas in the generated docs, and the/oauth/tokenresponse schema was never modeled with fields at all (pre-existing pattern — many endpoints in this file just have"description": "OK"). Unrelated to this PR's diff; a follow-up would be to disambiguate with[SchemaName]/nested-type naming so OpenAPI resolves the correct OAuth-specific record.
Low / Info
None.
Subagents
| Agent | Verdict |
|---|---|
| security-reviewer | PASS — independently re-verified script-context JSON encoding is XSS-safe, nonce introduces no new DoS vector beyond pre-existing unbounded redirectUri/codeChallenge/clientId fields (all bounded by the global Kestrel request-size limit), and the state-required check short-circuits before any external/expensive work in all three endpoints (Authorize L132-133 before Render, VerifyCode L168-169 before mediator.Send, GoogleAuth L199-200 before the Google API call) |
| contract-aligner | NOT VERIFIABLE — orbit-ui-mobile not checked out in this job. PR author states /oauth/* serves the external Claude.ai MCP client only, not packages/shared's contract surface; consistent with all prior reviews' conclusion and with no nonce/OAuth DTO fields appearing in typical mobile-consumed endpoints. |
Validation
CI at review time: Build SUCCESS, Guard Migrations/Conventions SUCCESS, OpenAPI Breaking-Change Gate SUCCESS, Dependency Review/Scan SUCCESS, GitGuardian SUCCESS, CodeQL NEUTRAL; Unit Tests / Mutation testing / SonarCloud handled as separate required checks (skipped here per this workflow's CI adaptation). Prior review recorded 4684 passed / 0 failed on this exact code.
What's good
- CSRF hardening (
staterequired, checked before any external call) is exactly right and closes a real gap — no prior negative-path test coverage forstateexisted at all. - The
noncebinding correctly rides on the existing single-use, 5-minute-expiry, PKCE-boundAuthorizationEntry, and is only echoed back when present (backward-compatible, opt-in). - The
WebUtility.HtmlEncode→JsonSerializer.Serializeswitch is a genuine security fix (wrong-encoding-for-sink class of bug), not just cosmetic. - Test coverage is thorough: state-negative-path (including the query-injection percent-encoding proof), nonce round-trip through both
verify-codeandgooglepaths into the token echo, and the store-level nonce binding — all new behavior is directly exercised.
Recommendation
No action needed — safe to merge. This diff has now been independently reviewed four times with zero Critical/High findings each time; the only carried-over item is a pre-existing, unrelated openapi.json schema-collision noted as a non-blocking follow-up.



What
Hardens the OAuth/MCP authorization flow (
/oauth/*, consumed by the Claude.ai MCP client) on two fronts.(a) Optional
noncereplay-protection layer/oauth/authorizenow accepts an optionalnoncequery param (OAuth/OIDC spec)./oauth/verify-code+/oauth/google→ is bound to the single-use authorization code inOAuthAuthorizationStore(AuthorizationEntry.Nonce)./oauth/tokenechoes the bound nonce in the response only when present, so the client can bind its authorize-time nonce to the token it receives and detect a replayed/substituted token response. This is the OIDC nonce model adapted to this bearer flow (no ID token here).(b) State negative coverage + hardening
OAuthControllerTestspreviously had no negative coverage forstate. Added:state→invalid_requestat/oauth/authorize,/oauth/verify-code,/oauth/google(and the guard short-circuits before verifying the code / calling Google);stateis echoed verbatim so a client-side mismatch is detectable;statecarrying query-injection (benign&code=forged-code&x=) is percent-encoded — proving an attacker cannot smuggle a forgedcodeparam into the redirect.stateis rejected server-side rather than silently accepted, enforcing CSRF protection (defense-in-depth alongside the already-mandatory PKCE/S256).Incidental hardening (required to add
noncesafely)OAuthLoginPageembedded server values into a<script>JS-string context viaHtmlEncode, which does not escape\and is the wrong encoding for that sink (latent script-string injection). Switched toJsonSerializer.Serialize(HTML-safe default encoder escapes< > & ' "), which fixes the latent injection and carries the new nonce safely.Tests
OAuthAuthorizationStoreTests: nonce round-trip (bound + returned verbatim; null when absent).OAuthControllerTests: 4 nonce tests (direct echo present/absent + end-to-end through verify-code and google into the token echo) and 5 state negative tests.dotnet buildclean.Notes
/oauth/*endpoints serve the external Claude.ai MCP client, notorbit-ui-mobile'spackages/sharedcontract, so no consumer/shared-type change applies (parity nudge is a false positive here). The token-responsenonceis append-only/optional and backward-compatible.openapi.jsonis the build-generated surface doc, updated in sync with the newnonceparam.Refs thomasluizon/orbit-ui-mobile#243