Skip to content

Allow DPoP-bound OAuth access tokens#1395

Merged
brendan-kellam merged 7 commits into
mainfrom
brendan/sou-947-allow-sender-constraining-access-tokens-ex-dpop
Jun 29, 2026
Merged

Allow DPoP-bound OAuth access tokens#1395
brendan-kellam merged 7 commits into
mainfrom
brendan/sou-947-allow-sender-constraining-access-tokens-ex-dpop

Conversation

@brendan-kellam

@brendan-kellam brendan-kellam commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add optional DPoP thumbprint binding to OAuth authorization codes, access tokens, and refresh tokens
  • validate ES256 DPoP proof JWTs for token issuance and MCP resource requests
  • advertise DPoP support in OAuth discovery metadata and MCP auth challenges

Testing

  • yarn workspace @sourcebot/db prisma:generate
  • yarn workspace @sourcebot/web vitest run src/ee/features/oauth/server.test.ts src/ee/features/oauth/dpop.test.ts src/middleware/withAuth.test.ts
  • yarn workspace @sourcebot/web lint
  • yarn workspace @sourcebot/db build

Summary by CodeRabbit

  • New Features
    • Added DPoP-bound OAuth flows, including dpop_jkt capture/validation on the authorization page and propagation through token exchanges; token responses now return the appropriate token type when bound.
    • OAuth metadata now advertises DPoP signing-algorithm support, and protected-resource metadata now declares supported bearer methods; unauthorized MCP discovery includes both Bearer and DPoP challenges.
  • Bug Fixes
    • Enforced DPoP authentication with signature/proof binding validation and replay protection, including stricter authorization-scheme handling and request-context aware token verification.
  • Tests
    • Added/extended coverage for DPoP verification, OAuth server exchange/rotation behavior, middleware auth, and request context handling.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ed351792-008e-45e2-aa24-9c9c9dd04dcb

📥 Commits

Reviewing files that changed from the base of the PR and between 77d3864 and 5867c46.

📒 Files selected for processing (2)
  • packages/web/src/ee/features/oauth/dpop.test.ts
  • packages/web/src/ee/features/oauth/dpop.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/ee/features/oauth/dpop.ts

Walkthrough

Adds DPoP sender-constrained OAuth token support across storage, authorization, token issuance, discovery metadata, and request authentication. It also adds request-scoped context handling for API handlers and updates tests and mocks for the new DPoP fields and flows.

Changes

DPoP OAuth Token Binding

Layer / File(s) Summary
DB schema, migration, mocks, changelog
packages/db/prisma/migrations/.../migration.sql, packages/db/prisma/schema.prisma, packages/web/src/__mocks__/prisma.ts, CHANGELOG.md
Adds nullable dpopJkt fields to OAuth authorization code, refresh token, and access token records, updates Prisma mocks with dpopJkt: null, and records the feature in the changelog.
Request context plumbing
packages/web/src/lib/requestContext.ts, packages/web/src/lib/apiHandler.ts, packages/web/src/lib/apiHandler.test.ts
Introduces async-local request storage and runs API handlers inside a request context so later auth code can read the current request.
DPoP verification module and tests
packages/web/src/ee/features/oauth/dpop.ts, packages/web/src/ee/features/oauth/dpop.test.ts
Adds DPoP proof verification, thumbprint calculation, access-token hashing, replay protection, and test coverage for valid proofs, binding failures, and replayed jti values.
OAuth issuance and rotation
packages/web/src/ee/features/oauth/server.ts, packages/web/src/ee/features/oauth/server.test.ts
Threads dpopJkt through auth-code generation, code exchange, and refresh rotation, enforces binding checks, persists the thumbprint on issued tokens, and updates tests for DPoP-bound and mismatch cases.
Authorize page, consent screen, and approval action
packages/web/src/app/oauth/authorize/page.tsx, packages/web/src/app/oauth/authorize/components/consentScreen.tsx, packages/web/src/ee/features/oauth/actions.ts
Accepts dpop_jkt on the authorization page, validates it, passes it through the consent flow, and forwards it into auth-code generation.
Token endpoint, discovery metadata, and MCP challenge
packages/web/src/app/api/(server)/ee/oauth/token/route.ts, packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts, packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts, packages/web/src/app/api/(server)/ee/mcp/route.ts
Verifies DPoP proofs at the token endpoint, propagates the resulting thumbprint into token issuance, sets DPoP-aware token type responses, and exposes DPoP/Bearer discovery and challenge metadata.
Request-aware authentication and DPoP enforcement
packages/web/src/middleware/withAuth.ts, packages/web/src/middleware/withAuth.test.ts
Parses authorization schemes, reads request headers from request context, verifies DPoP-bound OAuth tokens, rejects scheme mismatches, and adds tests for the new auth behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is clear and directly related to the core change: adding DPoP-bound OAuth tokens, though the PR also covers auth codes, refresh tokens, and metadata.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/sou-947-allow-sender-constraining-access-tokens-ex-dpop

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@brendan-kellam brendan-kellam marked this pull request as ready for review June 29, 2026 20:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/web/src/ee/features/oauth/dpop.test.ts (1)

34-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add negative coverage for request and key binding.

The suite verifies ath and replay failures, but not wrong htu/htm or expectedJkt mismatch. Those are core sender-constrained checks and cheap regression tests.

Suggested tests
+    test('rejects a proof for a different request URI', async () => {
+        const keyPair = await generateKeyPair();
+        const request = new Request('http://internal.test/api/ee/oauth/token', { method: 'POST' });
+        const proof = await signDpopProof({
+            ...keyPair,
+            htm: 'POST',
+            htu: 'https://sourcebot.test/api/ee/oauth/other',
+        });
+
+        await expect(verifyDpopProof({ request, proof })).resolves.toMatchObject({
+            ok: false,
+            error: 'invalid_dpop_proof',
+        });
+    });
+
+    test('rejects a proof signed by a different key than the expected binding', async () => {
+        const keyPair = await generateKeyPair();
+        const otherKeyPair = await generateKeyPair();
+        const request = new Request('http://internal.test/api/ee/oauth/token', { method: 'POST' });
+        const proof = await signDpopProof({
+            ...keyPair,
+            htm: 'POST',
+            htu: 'https://sourcebot.test/api/ee/oauth/token',
+        });
+
+        await expect(verifyDpopProof({
+            request,
+            proof,
+            expectedJkt: calculateDpopJkt(otherKeyPair.publicJwk),
+        })).resolves.toMatchObject({
+            ok: false,
+            error: 'invalid_dpop_proof',
+        });
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/ee/features/oauth/dpop.test.ts` around lines 34 - 120, Add
negative coverage to verifyDpopProof for sender-constrained checks that are
currently missing. Extend the dpop.test.ts suite with tests around
verifyDpopProof that reject a proof when the request method/URL binding is wrong
(mismatched htm and htu) and when the key binding is wrong (expectedJkt does not
match the proof’s public key). Reuse the existing generateKeyPair,
signDpopProof, calculateDpopJkt, and verifyDpopProof helpers to keep the tests
focused and consistent with the current cases.
packages/web/src/ee/features/oauth/dpop.ts (1)

49-49: 🔒 Security & Privacy | 🔵 Trivial

Verify replay protection across production instances.

seenProofJtis is process-local, so replay prevention will not hold across multiple Node workers/serverless instances; the unbounded Map plus full cleanup scan can also become an availability risk. Please confirm single-process deployment or move this to a bounded shared TTL store.

Also applies to: 241-255

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/ee/features/oauth/dpop.ts` at line 49, The replay cache in
dpop.ts is only process-local, so DPoP replay protection will fail across
multiple workers or instances and the unbounded seenProofJtis Map plus cleanup
scan can hurt availability. Update the DPoP proof handling around seenProofJtis
and the related replay-check logic in the verifier to use a bounded shared
TTL-backed store, or explicitly guard it so it only runs in a single-process
deployment; keep the unique jti tracking semantics but remove reliance on
in-memory process state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/src/app/oauth/authorize/page.tsx`:
- Around line 30-37: The OAuth authorize page currently accepts repeated or
empty dpop_jkt values by collapsing arrays to the first entry and only
validating when a truthy value exists. Update the validation in
authorize/page.tsx so the handling around params destructuring and the consent
gate rejects repeated dpop_jkt values as invalid, and also validates when
dpop_jkt is present even if empty. Use the existing authorize-page flow and the
dpopJkt/resource parsing logic to ensure any supplied dpop_jkt parameter must be
a single non-empty value before consent is shown.

In `@packages/web/src/ee/features/oauth/dpop.ts`:
- Around line 155-176: The DPoP replay cache in the validation flow is expiring
`jti` entries based on the current time instead of the proof’s accepted `iat`
boundary, so update the `recordProofJti()` call site in
`validateDpopProof`/`invalidDpopProof` flow to compute expiration from
`payload.iat` plus `DPOP_PROOF_IAT_WINDOW_SECONDS`. Also move
`recordProofJti(jkt, payload.jti)` to after the `ath` checks so a proof is only
recorded once it has fully passed validation.
- Around line 102-113: The DPoP parsing path in dpop.ts needs to verify the
decoded JWT values are plain objects before reading properties. In the block
that assigns header and payload, keep the JSON.parse handling but add an
object-shape guard for both values before using header.typ in validateDpopProof,
so malformed JSON like null, arrays, or primitives returns invalidDpopProof
instead of throwing. Use the existing DpopHeader and DpopPayload parsing flow
and the invalidDpopProof helper to keep the failure path consistent.

---

Nitpick comments:
In `@packages/web/src/ee/features/oauth/dpop.test.ts`:
- Around line 34-120: Add negative coverage to verifyDpopProof for
sender-constrained checks that are currently missing. Extend the dpop.test.ts
suite with tests around verifyDpopProof that reject a proof when the request
method/URL binding is wrong (mismatched htm and htu) and when the key binding is
wrong (expectedJkt does not match the proof’s public key). Reuse the existing
generateKeyPair, signDpopProof, calculateDpopJkt, and verifyDpopProof helpers to
keep the tests focused and consistent with the current cases.

In `@packages/web/src/ee/features/oauth/dpop.ts`:
- Line 49: The replay cache in dpop.ts is only process-local, so DPoP replay
protection will fail across multiple workers or instances and the unbounded
seenProofJtis Map plus cleanup scan can hurt availability. Update the DPoP proof
handling around seenProofJtis and the related replay-check logic in the verifier
to use a bounded shared TTL-backed store, or explicitly guard it so it only runs
in a single-process deployment; keep the unique jti tracking semantics but
remove reliance on in-memory process state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: efedc7d9-2516-4068-9ac1-044fefee9bc8

📥 Commits

Reviewing files that changed from the base of the PR and between f7f0fef and 4d5b9f4.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • packages/db/prisma/migrations/20260629193000_add_oauth_dpop_binding/migration.sql
  • packages/db/prisma/schema.prisma
  • packages/web/src/__mocks__/prisma.ts
  • packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts
  • packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts
  • packages/web/src/app/api/(server)/ee/mcp/route.ts
  • packages/web/src/app/api/(server)/ee/oauth/token/route.ts
  • packages/web/src/app/oauth/authorize/components/consentScreen.tsx
  • packages/web/src/app/oauth/authorize/page.tsx
  • packages/web/src/ee/features/oauth/actions.ts
  • packages/web/src/ee/features/oauth/dpop.test.ts
  • packages/web/src/ee/features/oauth/dpop.ts
  • packages/web/src/ee/features/oauth/server.test.ts
  • packages/web/src/ee/features/oauth/server.ts
  • packages/web/src/middleware/withAuth.test.ts
  • packages/web/src/middleware/withAuth.ts

Comment thread packages/web/src/app/oauth/authorize/page.tsx
Comment thread packages/web/src/ee/features/oauth/dpop.ts Outdated
Comment thread packages/web/src/ee/features/oauth/dpop.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/web/src/app/api/(server)/ee/mcp/route.ts (1)

23-32: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Align the WWW-Authenticate auth-param with the MCP discovery parser.

extractResourceMetadataUrl() in packages/web/src/ee/features/chat/mcp/dcrDiscovery.ts:93-113 only matches resource_metadata="...". Advertising resource_metadata_uri="..." here means those 401 challenges are ignored, so the MCP client never learns the protected-resource metadata URL from this response. Update both sides to use the same parameter name in this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/app/api/`(server)/ee/mcp/route.ts around lines 23 - 32, The
`WWW-Authenticate` challenges in the MCP route use `resource_metadata_uri`, but
`extractResourceMetadataUrl()` in `dcrDiscovery.ts` only parses
`resource_metadata`, so the 401 response is ignored by the client. Update the
auth-param name in the route handler that appends the `Bearer` and `DPoP`
headers, and make sure the MCP discovery parser expects the same parameter name
so both sides match consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/web/src/app/api/`(server)/ee/mcp/route.ts:
- Around line 23-32: The `WWW-Authenticate` challenges in the MCP route use
`resource_metadata_uri`, but `extractResourceMetadataUrl()` in `dcrDiscovery.ts`
only parses `resource_metadata`, so the 401 response is ignored by the client.
Update the auth-param name in the route handler that appends the `Bearer` and
`DPoP` headers, and make sure the MCP discovery parser expects the same
parameter name so both sides match consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bbe9430f-e9ba-4294-a5e6-bd2c52b6d832

📥 Commits

Reviewing files that changed from the base of the PR and between 4d5b9f4 and e097291.

📒 Files selected for processing (6)
  • packages/web/src/app/api/(server)/ee/mcp/route.ts
  • packages/web/src/lib/apiHandler.test.ts
  • packages/web/src/lib/apiHandler.ts
  • packages/web/src/lib/requestContext.ts
  • packages/web/src/middleware/withAuth.test.ts
  • packages/web/src/middleware/withAuth.ts

@brendan-kellam brendan-kellam merged commit 649bfbf into main Jun 29, 2026
10 checks passed
@brendan-kellam brendan-kellam deleted the brendan/sou-947-allow-sender-constraining-access-tokens-ex-dpop branch June 29, 2026 23:00
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.

1 participant