diff --git a/CONCEPTS.md b/CONCEPTS.md index 0a8fcebb0..c17ce0942 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -2,6 +2,30 @@ Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. +## Application access + +### Registered Application + +A Jesus Film product or service recognized by Auth as an application-access boundary, with its own ownership, trust posture, lifecycle, deployment environments, grants, and issued tokens. + +### Application Environment + +A deployment-specific authorization boundary within a Registered Application that carries the OAuth client posture and approval state against which grants and tokens are evaluated. + +### Application Grant + +An explicit, revocable approval that gives a user or service a set of scopes for one Registered Application and Application Environment; an OAuth client's allowed scopes do not constitute an Application Grant. + +### Dynamic MCP Client + +A public OAuth client created at runtime by an MCP host so that each host can establish its own callback metadata and client identity without a pre-seeded credential. + +Registering a Dynamic MCP Client identifies the client but grants no application access; authorization still depends on an applicable Application Grant, and the companion MCP resource implementation independently enforces the issued token. + +## Relationships + +A Registered Application contains Application Environments. Application Grants and issued tokens target an Application Environment, while a Dynamic MCP Client requests access to the protected resource associated with that environment. + ## Devotional generation ### Devotional Workspace diff --git a/apps/auth/src/app/api/auth/[...all]/route.test.ts b/apps/auth/src/app/api/auth/[...all]/route.test.ts index 86ad82dcf..d3bbf625c 100644 --- a/apps/auth/src/app/api/auth/[...all]/route.test.ts +++ b/apps/auth/src/app/api/auth/[...all]/route.test.ts @@ -100,6 +100,147 @@ describe("Auth route wrapper", () => { vi.unstubAllEnvs() }) + it("normalizes implicit web loopback DCR clients to the native application type", async () => { + authPost.mockResolvedValueOnce( + Response.json({ client_id: "claude_dynamic" }), + ) + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost:3004/api/auth/oauth2/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "Claude Code", + redirect_uris: ["http://localhost:3118/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }), + }), + { params: Promise.resolve({ all: ["oauth2", "register"] }) }, + ) + + expect(response.status).toBe(200) + const forwarded = authPost.mock.calls[0]?.[0] as Request + await expect(forwarded.json()).resolves.toMatchObject({ + application_type: "native", + redirect_uris: ["http://localhost:3118/callback"], + token_endpoint_auth_method: "none", + }) + }) + + it.each([ + { + name: "an explicit web client", + body: { + application_type: "web", + redirect_uris: ["http://localhost:3118/callback"], + }, + }, + { + name: "an explicit native client", + body: { + application_type: "native", + redirect_uris: ["http://localhost:3118/callback"], + }, + }, + { + name: "an explicit confidential client", + body: { + redirect_uris: ["http://localhost:3118/callback"], + token_endpoint_auth_method: "client_secret_basic", + }, + }, + { + name: "a public HTTP redirect", + body: { redirect_uris: ["http://example.com/callback"] }, + }, + { + name: "mixed loopback and public redirects", + body: { + redirect_uris: [ + "http://127.0.0.1:3118/callback", + "https://example.com/callback", + ], + }, + }, + { + name: "an empty redirect list", + body: { redirect_uris: [] }, + }, + ])("does not normalize $name", async ({ body }) => { + authPost.mockResolvedValueOnce(Response.json({ client_id: "dynamic" })) + const { POST } = await import("./route") + await POST( + new Request("http://localhost:3004/api/auth/oauth2/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ all: ["oauth2", "register"] }) }, + ) + + const forwarded = authPost.mock.calls[0]?.[0] as Request + await expect(forwarded.json()).resolves.toEqual(body) + }) + + it.each(["http://127.0.0.1:49173/callback", "http://[::1]:49173/callback"])( + "normalizes implicit loopback redirect %s", + async (redirectUri) => { + authPost.mockResolvedValueOnce(Response.json({ client_id: "dynamic" })) + const { POST } = await import("./route") + await POST( + new Request("http://localhost:3004/api/auth/oauth2/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ redirect_uris: [redirectUri] }), + }), + { params: Promise.resolve({ all: ["oauth2", "register"] }) }, + ) + + const forwarded = authPost.mock.calls[0]?.[0] as Request + await expect(forwarded.json()).resolves.toEqual({ + application_type: "native", + redirect_uris: [redirectUri], + token_endpoint_auth_method: "none", + }) + }, + ) + + it("rejects oversized DCR registration bodies case-insensitively", async () => { + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost:3004/api/auth/oauth2/register", { + method: "POST", + headers: { "content-type": "Application/JSON" }, + body: JSON.stringify({ padding: "x".repeat(64 * 1024) }), + }), + { params: Promise.resolve({ all: ["oauth2", "register"] }) }, + ) + + expect(response.status).toBe(413) + expect(authPost).not.toHaveBeenCalled() + }) + + it("preserves malformed DCR JSON for the provider", async () => { + authPost.mockResolvedValueOnce(Response.json({ error: "invalid_request" })) + const { POST } = await import("./route") + await POST( + new Request("http://localhost:3004/api/auth/oauth2/register", { + method: "POST", + headers: { + "content-length": "1", + "content-type": "application/json", + }, + body: "{", + }), + { params: Promise.resolve({ all: ["oauth2", "register"] }) }, + ) + + const forwarded = authPost.mock.calls[0]?.[0] as Request + expect(forwarded.headers.has("content-length")).toBe(false) + await expect(forwarded.text()).resolves.toBe("{") + }) + it("downscopes an authenticated Changelog authorize request before the provider sees it", async () => { getSession.mockResolvedValueOnce({ user: { id: "user_123", membershipStatus: "ACTIVE" }, diff --git a/apps/auth/src/app/api/auth/[...all]/route.ts b/apps/auth/src/app/api/auth/[...all]/route.ts index 85ed8169d..2bbad4069 100644 --- a/apps/auth/src/app/api/auth/[...all]/route.ts +++ b/apps/auth/src/app/api/auth/[...all]/route.ts @@ -39,6 +39,7 @@ const WINDOW_MS = 60_000 const MAX_ATTEMPTS = 10 const LAST_LOGIN_METHOD_COOKIE = "forge_auth_last_login_method" const LAST_LOGIN_METHOD_MAX_AGE = 60 * 60 * 24 * 365 +const MAX_DCR_BODY_BYTES = 64 * 1024 type LastLoginMethod = "apple" | "email" | "facebook" | "google" | "okta" const providerPriority = ["google", "facebook", "apple", "okta"] as const @@ -51,6 +52,113 @@ function isFormPostRequest(request: Request): boolean { ) } +function isHttpLoopbackRedirect(uri: string): boolean { + try { + const url = new URL(uri) + return ( + url.protocol === "http:" && + (url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]") + ) + } catch { + return false + } +} + +async function normalizeLoopbackDcrRequest( + request: Request, +): Promise { + if ( + !request.headers + .get("content-type") + ?.toLowerCase() + .includes("application/json") + ) { + return request + } + + const bodyBytes = await readBoundedBody(request, MAX_DCR_BODY_BYTES) + if (!bodyBytes) { + return Response.json( + { error: "Request body is too large" }, + { status: 413 }, + ) + } + const headers = new Headers(request.headers) + headers.delete("content-length") + const forward = (body: BodyInit) => + new Request(request.url, { + body, + headers, + method: request.method, + signal: request.signal, + }) + + let parsed: unknown + try { + parsed = JSON.parse(new TextDecoder().decode(bodyBytes)) + } catch { + return forward(bodyBytes) + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + return forward(bodyBytes) + } + const body = parsed as Record + const redirectUris = body.redirect_uris + if ( + body.application_type !== undefined || + (body.token_endpoint_auth_method !== undefined && + body.token_endpoint_auth_method !== "none") || + !Array.isArray(redirectUris) || + redirectUris.length === 0 || + !redirectUris.every( + (uri): uri is string => + typeof uri === "string" && isHttpLoopbackRedirect(uri), + ) + ) { + return forward(bodyBytes) + } + + return new Request(request.url, { + body: JSON.stringify({ + ...body, + application_type: "native", + token_endpoint_auth_method: "none", + }), + headers, + method: request.method, + signal: request.signal, + }) +} + +async function readBoundedBody( + request: Request, + maxBytes: number, +): Promise { + const reader = request.body?.getReader() + if (!reader) return new ArrayBuffer(0) + const chunks: Uint8Array[] = [] + let length = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + length += value.byteLength + if (length > maxBytes) { + await reader.cancel().catch(() => undefined) + return + } + chunks.push(value) + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes.buffer +} + function sha256(input: string): string { return createHash("sha256").update(input).digest("hex") } @@ -1052,6 +1160,11 @@ export async function POST( const policyResponse = await enforceChangelogConsentPolicy(request) if (policyResponse) return policyResponse } + if (path === "oauth2/register") { + const normalized = await normalizeLoopbackDcrRequest(request) + if (normalized instanceof Response) return normalized + request = normalized + } if (isDeviceGrantPath(path)) { return withNoStore(await authRouteHandlers.POST(request)) } diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index da94198d5..30e360ab2 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -7,11 +7,11 @@ Build trusted, scalable AI capabilities that help people discover gospel content ## Status (August 24, 2026) - **Total tickets:** 588 -- **Complete:** 431 +- **Complete:** 432 - **In progress:** 40 - **Not started:** 37 -- **Blocked:** 80 -- **Overdue and not complete:** 107 +- **Blocked:** 79 +- **Overdue and not complete:** 106 ## Feature Index @@ -276,7 +276,7 @@ Build trusted, scalable AI capabilities that help people discover gospel content | [feat-327](platform/feat-327-admin-prisma-adapter-pool-config.md) | Admin Prisma adapter pool config | codex | P0 | 2026-08-03 | 1 | 2026-08-03 | complete | | [feat-352](platform/feat-352-mastra-seo-live-proposal-digest.md) | Align live SEO proposal digest with Admin persistence | codex | P0 | 2026-08-11 | 1 | 2026-08-11 | complete | | [feat-356](platform/feat-356-preserve-iso-dates-in-seo-report-redaction.md) | Preserve ISO dates in SEO report redaction | codex | P0 | 2026-08-11 | 1 | 2026-08-11 | complete | -| [feat-399](platform/feat-399-changelog-first-party-auth.md) | Register Changelog with first-party Auth grants | edmonday | P0 | 2026-08-19 | 3 | 2026-08-21 | blocked | +| [feat-399](platform/feat-399-changelog-first-party-auth.md) | Register Changelog with first-party Auth grants | edmonday | P0 | 2026-08-19 | 3 | 2026-08-21 | complete | | [feat-401](platform/feat-401-better-auth-native-resource-upgrade.md) | Upgrade Better Auth for native resource binding | edmonday | P0 | 2026-08-20 | 4 | 2026-08-23 | complete | | [feat-402](platform/feat-402-mobile-expo-sdk57-patch-alignment.md) | Mobile Expo SDK 57 patch alignment | edmonday | P0 | 2026-08-21 | 1 | 2026-08-21 | complete | | [feat-278](platform/feat-278-watch-russian-authored-content-localization.md) | Watch Russian authored content localization | unassigned | P1 | — | 2 | — | not-started | diff --git a/docs/roadmap/platform/feat-399-changelog-first-party-auth.md b/docs/roadmap/platform/feat-399-changelog-first-party-auth.md index 2eaecb404..9e77e1861 100644 --- a/docs/roadmap/platform/feat-399-changelog-first-party-auth.md +++ b/docs/roadmap/platform/feat-399-changelog-first-party-auth.md @@ -3,7 +3,7 @@ id: "feat-399" title: "Register Changelog with first-party Auth grants" owner: "edmonday" priority: "P0" -status: "in-progress" +status: "complete" start_date: "2026-08-19" duration: 3 depends_on: @@ -100,12 +100,52 @@ grant revocation before code exchange and refresh, zero token rows on denial, cross-resource rejection, and production-off behavior. Production issuance remains default-off. -This ticket stays `in-progress` until separate clean Codex and Claude client -profiles prove distinct dynamic client identities and token families through -authorize, exchange, refresh/reconnect, and a denied ungranted scope/tool. -That acceptance is externally blocked as of `JesusFilm/jfp-changelog` -`d403318`: its Auth verifier accepts the local `/mcp` audience, but the -repository does not yet implement a runnable `/mcp` protected-resource -endpoint or read tool for either client to connect to. -Supported grant provisioning and revocation also remain prerequisites for -enabling production. +Acceptance completed locally on 2026-08-24 against Forge `a4c9e3ba` and +`JesusFilm/jfp-changelog` `4babe81`. Changelog exposed a protected Streamable +HTTP `/mcp` endpoint plus the PostgreSQL-backed `list_entries` tool, and a +disposable Forge database contained one approved local `changelog:read` grant +and one published test entry. Separate clean CLI profiles completed dynamic +registration and authorization: + +- Codex registered native client `StXZJVoOsCKzKGgVWwUbrLSIvVOlHWGe` with a + `127.0.0.1` callback; Claude registered native client + `heFkQEOEPcbgyyZOYooMJFcEIuYhCDtU` with a `localhost` callback. Forge commit + `a4c9e3ba` normalizes omitted or implicit-web DCR metadata to `native` only + when every redirect is an exact HTTP loopback URI, preserving the provider's + HTTPS requirement for non-loopback web clients. +- Both authorization requests included an ungranted `changelog:submit` scope, + and both exchanged tokens contained exactly `changelog:read`. Separate + refresh-family SHA-256 prefixes (`fad10f525b54` for Codex and + `c6a16308cd2a` for Claude) proved the clients did not share credentials. +- Codex read `Protected MCP reads are available`, refreshed from access-token + digest `ef3860e2b443` to `8a326bee7eaa`, then read the same entry after + reconnect. Claude independently read the entry, refreshed from + `f3b57130de51` to `03e17068181d`, and read it again. +- A submit-only authorization request for each client returned + `access_denied` with no authorization code. Production remained disabled + throughout with `AUTH_CHANGELOG_PRODUCTION_ENABLED=false`. + +A post-fix receipt on 2026-08-25 covered the final public-client and MCP +hardening at Forge `91d9fdb5` and Changelog `858e2cf`. Fresh Codex-style and +Claude-style registrations created different native public clients, +`AamZxnppdsqdxOSuHjaKKJUtdEoiFEcs` and +`jciWzrwyCZZTFKehqWoPtuOfIuBcVjfM`, with no client secrets and with their +respective `127.0.0.1` and `localhost` callbacks. The Codex client then +completed authorization-code exchange with PKCE, received only `openid +changelog:read`, read `Protected MCP reads are available`, rotated both its +access and refresh tokens (`c89b8af74f8b` to `07536942db2b` and +`27b9be4bfd19` to `38e0b9de817f`), and read the entry again after refresh. The +temporary port-3999 metadata proxy used for this receipt did not forward the +Next.js development WebSocket, so its server-rendered consent page could not +hydrate; after explicit user approval, the same authenticated consent payload +was submitted directly to the consent endpoint. The earlier browser consent, +two-client lifecycle, independent token-family, and denied-capability evidence +above remains the end-to-end UI receipt. + +Verification passed with 468 Forge Auth unit tests (18 opt-in integration tests +skipped in the aggregate command), focused route tests, Auth typecheck and +lint, plus 104 Changelog tests and Changelog typecheck. The earlier PostgreSQL +CI receipt above covers all 18 Forge integration tests. Preview registration +remains deliberately deferred until Changelog has a stable preview domain. +Supported grant provisioning and revocation remain separate production- +readiness work and are still prerequisites for enabling production issuance. diff --git a/docs/solutions/auth/oauth-loopback-dynamic-client-registration-normalization.md b/docs/solutions/auth/oauth-loopback-dynamic-client-registration-normalization.md new file mode 100644 index 000000000..8becada75 --- /dev/null +++ b/docs/solutions/auth/oauth-loopback-dynamic-client-registration-normalization.md @@ -0,0 +1,205 @@ +--- +title: Normalize exact OAuth loopback clients before Better Auth dynamic registration +date: 2026-08-25 +category: auth +module: apps/auth +problem_type: integration_issue +component: authentication +symptoms: + - Codex and Claude dynamic registrations failed when they omitted application_type and used exact HTTP loopback callbacks + - Better Auth classified the omitted type as web and rejected the local HTTP redirect before authorization could begin + - The unauthenticated registration adapter could buffer JSON before registration rate limiting applied +root_cause: logic_error +resolution_type: code_fix +severity: medium +related_components: + - mcp + - api_layer + - testing_framework +tags: + - better-auth + - oauth + - dynamic-client-registration + - loopback-redirect + - mcp + - codex + - claude + - request-bounds +--- + +# Normalize exact OAuth loopback clients before Better Auth dynamic registration + +## Problem + +Forge Auth's OAuth Dynamic Client Registration boundary did not understand the +registration shape used by local MCP clients such as Codex and Claude. The +supported public-client flow requires PKCE and uses a temporary loopback +callback listener, but clients may omit `application_type`. Better Auth treated the omission as a web application +and rejected otherwise valid native-client callbacks such as +`http://127.0.0.1:49173/callback`. + +This was a provider-boundary integration issue, not a reason to relax redirect +validation globally. The adapter needed to translate one unambiguous client +shape into metadata the provider already validates, without turning the +unauthenticated registration endpoint into an unbounded JSON-buffering surface. + +Registration creates an OAuth client identity; it does not grant Changelog +access. Forge separately evaluates the user, target environment, scopes, and +approved grants during authorization, exchange, and refresh +(`apps/auth/src/services/changelog-oauth-grant.service.ts:109-183`). The +companion Changelog implementation in +[PR #79](https://github.com/JesusFilm/jfp-changelog/pull/79) separately validates +the resulting bearer token and `changelog:read` capability at `/mcp`. + +The implementation is pending merge in +[Forge PR #2021](https://github.com/JesusFilm/forge/pull/2021). + +## Symptoms + +- Codex- and Claude-style registrations failed when they omitted + `application_type` and supplied HTTP loopback callbacks. +- The same HTTP URI is invalid for an ordinary web client but expected for a + native CLI listening temporarily on a local port. +- A broad workaround risked accepting public HTTP redirects or overriding + metadata the caller deliberately supplied. +- Reading and rewriting the public registration request introduced a second + risk: the body needed a byte limit before JSON parsing. + +A minimal failing request was: + +```json +{ + "client_name": "Claude Code", + "redirect_uris": ["http://localhost:3118/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"] +} +``` + +Treating every omitted type as `native` would be too permissive. Treating this +specific local shape as `web` blocks a valid native flow. + +## What Didn't Work + +- Relying on the provider default failed because omitted `application_type` + became `web` before the HTTP callback was evaluated. The compatibility seam + therefore has to run before the provider handler + (`apps/auth/src/app/api/auth/[...all]/route.ts:1163-1172`). +- Relaxing HTTPS for all web registrations would fix the symptom at the wrong + layer and allow insecure public-network callbacks. The accepted exception is + structural: `http:` plus the exact parsed hostname `localhost`, `127.0.0.1`, + or `[::1]` (`apps/auth/src/app/api/auth/[...all]/route.ts:55-67`). +- Accepting a registration when only one of several redirects is loopback would + permit a public redirect beside the safe local one. Every redirect must pass + the predicate (`apps/auth/src/app/api/auth/[...all]/route.ts:108-120`). +- Pre-seeding a shared client would bypass the registration failure, but would + not prove that Codex and Claude establish independent identities and token + families. (session history) +- Overwriting explicit `application_type` or confidential-client metadata would + change the caller's chosen security model. Those requests stay provider-owned + (`apps/auth/src/app/api/auth/[...all]/route.ts:109-120`). +- Parsing before enforcing a body limit exposed an unauthenticated endpoint to + excessive buffering. Matching `application/json` case-sensitively was also + insufficient because downstream parsing accepts mixed-case media types + (`apps/auth/src/app/api/auth/[...all]/route.ts:72-87`). + +## Solution + +Add a pre-provider adapter only for `POST /oauth2/register`: + +1. Forward non-JSON requests unchanged. +2. Stream at most 64 KiB before decoding JSON. Cancel the reader and return 413 + after the limit (`apps/auth/src/app/api/auth/[...all]/route.ts:42,81-87,135-160`). +3. Remove `Content-Length` when rebuilding a request so a changed body does not + retain a stale length (`apps/auth/src/app/api/auth/[...all]/route.ts:88-96`). +4. Forward malformed, primitive, null, or array JSON unchanged so Better Auth + retains schema-error ownership (`apps/auth/src/app/api/auth/[...all]/route.ts:98-107`). +5. Normalize only when `application_type` is omitted, + `token_endpoint_auth_method` is omitted or `none`, and every redirect is an + exact HTTP loopback (`apps/auth/src/app/api/auth/[...all]/route.ts:108-121`). +6. Add `application_type: "native"` and + `token_endpoint_auth_method: "none"`, preserve all other fields, and delegate + to Better Auth (`apps/auth/src/app/api/auth/[...all]/route.ts:123-132`). + +Before: + +```text +omitted application_type + http://127.0.0.1:/callback +→ provider default: web client +→ HTTP callback rejected +``` + +After: + +```text +omitted application_type + every callback is exact HTTP loopback +→ adapter supplies native public-client metadata +→ provider performs normal registration validation +``` + +Counterexample: + +```text +omitted application_type + http://example.com/callback +→ adapter makes no change +→ provider retains authority to reject the insecure callback +``` + +Focused tests cover `localhost`, IPv4, and IPv6 success cases +(`apps/auth/src/app/api/auth/[...all]/route.test.ts:103-129,186-207`), explicit +and ambiguous pass-through cases (`route.test.ts:131-184`), mixed-case +oversized JSON (`route.test.ts:209-222`), and malformed JSON forwarding without +a stale length header (`route.test.ts:224-242`). + +## Why This Works + +A local CLI callback is distinguishable without trusting a client name or user +agent. Parsing the URL and requiring both `http:` and one of three exact +loopback hostnames makes the exception structural rather than substring-based. +Requiring every callback to match prevents a safe URI from laundering a public +one (`apps/auth/src/app/api/auth/[...all]/route.ts:55-67,113-118`). + +This remains compatibility normalization rather than a second OAuth validator. +Explicit or ambiguous registrations stay untouched, malformed content remains +the provider's concern, and the existing handler is still the final +registration authority (`apps/auth/src/app/api/auth/[...all]/route.ts:98-120,1163-1172`). + +`token_endpoint_auth_method: "none"` matches an installed client that cannot +keep a client secret. It still grants nothing. Changelog authorization is +recognized and downscoped separately (`route.ts:658-712`), while the grant +service requires an active user, approved environment, approved grants, and the +production activation decision (`changelog-oauth-grant.service.ts:113-183`). + +The byte cap protects the new adapter itself. It counts streamed bytes instead +of trusting `Content-Length`, cancels after the ceiling, and parses only the +bounded result. Lowercasing the media type keeps the boundary aligned with HTTP +header semantics (`route.ts:72-100,135-160`). + +## Prevention + +- Test both sides of the classifier: omitted metadata with `localhost`, + `127.0.0.1`, and `[::1]`; then explicit web/native types, confidential token + methods, public hosts, mixed lists, and empty lists + (`route.test.ts:103-207`). +- Add malformed URLs, loopback-looking subdomains, user-info tricks, and + alternate IP spellings before expanding the accepted host set. +- Test the size limit at the boundary and one byte over, including mixed-case + and parameterized JSON content types. Assert that the provider is not called + after a 413 (`route.test.ts:209-222`). +- Keep forwarding tests for malformed JSON, request cancellation, and stale + `Content-Length`; request reconstruction can regress independently of OAuth + policy (`route.test.ts:224-242`). +- Exercise two clean clients end to end: distinct registrations and token + families, PKCE exchange, refresh/reconnect, a granted MCP read, and denial of + an ungranted capability. Keep registration, Forge grant enforcement, and + Changelog resource enforcement as separate assertions. (session history) +- Do not whitelist client names, infer privilege from successful registration, + or move user grants and MCP authorization into this adapter. + +## Related Issues + +- [Forge PR #2021](https://github.com/JesusFilm/forge/pull/2021) — implementation pending merge +- [Changelog issue #71](https://github.com/JesusFilm/jfp-changelog/issues/71) — primary integration contract +- [Better Auth authorization resource binding upgrade](./better-auth-authorization-resource-binding-upgrade.md) — prerequisite provider safety +- [OAuth-protected MCP tool parity pattern](../architecture-patterns/oauth-protected-mcp-tool-parity-pattern-20260721.md) — downstream resource-server boundary +- [Buffered HTTP response byte-cap guard](../best-practices/buffered-http-response-byte-cap-oom-guard-20260629.md) — related stream-limiting pattern