From 6dd3f84a1e7a5ea4181a98966d9d34d5617e1755 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 21:33:09 -0700 Subject: [PATCH 1/2] chore(web): guard OAuth API routes against 307/308 redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `oauthApiHandler`, a thin wrapper around `apiHandler` that throws if the wrapped handler ever returns an HTTP 307 or 308 response. Per RFC 9700 §4.12, an OAuth authorization server must not use 307/308 on redirects that could carry user credentials, since those status codes preserve the request method and body. Wires `oauthApiHandler` into all five authorization-server route handlers — the token, register, and revoke endpoints under `/api/ee/oauth/*`, plus the two RFC 8414 / RFC 9728 discovery endpoints under `/api/ee/.well-known/*` — so any future change that accidentally introduces a 307/308 from these routes throws at request time rather than silently shipping. Includes unit tests verifying the wrapper passes through 200/302/303/400 responses unchanged and throws on 307 and 308. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oauth-authorization-server/route.ts | 4 +- .../[...path]/route.ts | 4 +- .../api/(server)/ee/oauth/register/route.ts | 4 +- .../app/api/(server)/ee/oauth/revoke/route.ts | 4 +- .../app/api/(server)/ee/oauth/token/route.ts | 4 +- .../src/ee/features/oauth/apiHandler.test.ts | 53 +++++++++++++++++++ .../web/src/ee/features/oauth/apiHandler.ts | 32 +++++++++++ 7 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 packages/web/src/ee/features/oauth/apiHandler.test.ts create mode 100644 packages/web/src/ee/features/oauth/apiHandler.ts diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts index cdb71f642..89eb29b5a 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts @@ -1,10 +1,10 @@ -import { apiHandler } from '@/lib/apiHandler'; +import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; import { env, hasEntitlement } from '@sourcebot/shared'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; // RFC 8414: OAuth 2.0 Authorization Server Metadata // @see: https://datatracker.ietf.org/doc/html/rfc8414 -export const GET = apiHandler(async () => { +export const GET = oauthApiHandler(async () => { if (!hasEntitlement('oauth')) { return Response.json( { error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts index 2ec902530..17f4b843c 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts @@ -1,4 +1,4 @@ -import { apiHandler } from '@/lib/apiHandler'; +import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; import { env, hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -10,7 +10,7 @@ const PROTECTED_RESOURCES = new Set([ 'api/mcp' ]); -export const GET = apiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { +export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { if (!hasEntitlement('oauth')) { return Response.json( { error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, diff --git a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts index e315b3225..a69f8291e 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts @@ -1,4 +1,4 @@ -import { apiHandler } from '@/lib/apiHandler'; +import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; import { __unsafePrisma } from '@/prisma'; import { hasEntitlement } from '@sourcebot/shared'; @@ -14,7 +14,7 @@ const registerRequestSchema = z.object({ logo_uri: z.string().url().nullish(), }); -export const POST = apiHandler(async (request: NextRequest) => { +export const POST = oauthApiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, diff --git a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts index 69d98db95..468c6ec78 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts @@ -1,5 +1,5 @@ import { revokeToken } from '@/ee/features/oauth/server'; -import { apiHandler } from '@/lib/apiHandler'; +import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; import { hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -7,7 +7,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants // RFC 7009: OAuth 2.0 Token Revocation // Always returns 200 regardless of whether the token existed. // @see: https://datatracker.ietf.org/doc/html/rfc7009 -export const POST = apiHandler(async (request: NextRequest) => { +export const POST = oauthApiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts index a5934ffd4..ae93952d4 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts @@ -1,5 +1,5 @@ import { verifyAndExchangeCode, verifyAndRotateRefreshToken } from '@/ee/features/oauth/server'; -import { apiHandler } from '@/lib/apiHandler'; +import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; import { env, hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -7,7 +7,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants // OAuth 2.0 Token Endpoint // Supports grant_type=authorization_code with PKCE (RFC 7636). // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2 -export const POST = apiHandler(async (request: NextRequest) => { +export const POST = oauthApiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, diff --git a/packages/web/src/ee/features/oauth/apiHandler.test.ts b/packages/web/src/ee/features/oauth/apiHandler.test.ts new file mode 100644 index 000000000..79671c1c5 --- /dev/null +++ b/packages/web/src/ee/features/oauth/apiHandler.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { oauthApiHandler } from './apiHandler'; + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); + +const makeRequest = () => new NextRequest('http://localhost/api/ee/oauth/test', { method: 'POST' }); + +describe('oauthApiHandler', () => { + test('passes through a 200 response unchanged', async () => { + const handler = oauthApiHandler(async (_req: NextRequest) => Response.json({ ok: true }, { status: 200 })); + const res = await handler(makeRequest()); + expect(res.status).toBe(200); + }); + + test('passes through a 400 response unchanged', async () => { + const handler = oauthApiHandler(async (_req: NextRequest) => Response.json({ error: 'bad' }, { status: 400 })); + const res = await handler(makeRequest()); + expect(res.status).toBe(400); + }); + + test('passes through a 302 redirect unchanged', async () => { + const handler = oauthApiHandler(async (_req: NextRequest) => + new Response(null, { status: 302, headers: { Location: '/elsewhere' } }) + ); + const res = await handler(makeRequest()); + expect(res.status).toBe(302); + }); + + test('passes through a 303 redirect unchanged (the spec-recommended status)', async () => { + const handler = oauthApiHandler(async (_req: NextRequest) => + new Response(null, { status: 303, headers: { Location: '/elsewhere' } }) + ); + const res = await handler(makeRequest()); + expect(res.status).toBe(303); + }); + + test('throws when the inner handler returns 307', async () => { + const handler = oauthApiHandler(async (_req: NextRequest) => + new Response(null, { status: 307, headers: { Location: '/elsewhere' } }) + ); + await expect(handler(makeRequest())).rejects.toThrow(/RFC 9700/); + }); + + test('throws when the inner handler returns 308', async () => { + const handler = oauthApiHandler(async (_req: NextRequest) => + new Response(null, { status: 308, headers: { Location: '/elsewhere' } }) + ); + await expect(handler(makeRequest())).rejects.toThrow(/RFC 9700/); + }); +}); diff --git a/packages/web/src/ee/features/oauth/apiHandler.ts b/packages/web/src/ee/features/oauth/apiHandler.ts new file mode 100644 index 000000000..272cf601d --- /dev/null +++ b/packages/web/src/ee/features/oauth/apiHandler.ts @@ -0,0 +1,32 @@ +import { apiHandler } from '@/lib/apiHandler'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyHandler = (...args: any[]) => Promise | Response; + +/** + * Wraps `apiHandler` for routes that are part of the OAuth Authorization Server + * (`/api/ee/oauth/*`). + * + * Per RFC 9700 §4.12, the authorization server MUST avoid forwarding user + * credentials accidentally on redirect. 307 and 308 preserve the request + * method and body, so they MUST NOT be used for authorization-server + * redirects. This wrapper asserts that handlers never emit either status, + * giving us a runtime guarantee. + * + * @see https://datatracker.ietf.org/doc/html/rfc9700#section-4.12 + */ +export function oauthApiHandler(handler: H): H { + const wrapped = apiHandler(async (...args: Parameters) => { + const response = await handler(...args); + if (response.status === 307 || response.status === 308) { + throw new Error( + `OAuth authorization server emitted HTTP ${response.status} redirect; ` + + `per RFC 9700 §4.12 the authorization server MUST NOT use 307/308. ` + + `Use 303 (See Other) instead.` + ); + } + return response; + }); + + return wrapped as H; +} From afdbe91df3711b3257ff3ee769cef8a094033e3d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 21:34:01 -0700 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e546d31..b5de65a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `/api/avatar` to resolve user profile pictures. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159) - Hardened post-auth redirects with an explicit same-origin `redirect` callback in the NextAuth config, and switched the legacy `/~/...` URL rewrite from a 308 to a 301. [#1161](https://github.com/sourcebot-dev/sourcebot/pull/1161) - Made the Auth.js JWT session lifetime and OAuth token TTLs configurable via `AUTH_SESSION_MAX_AGE_SECONDS`, `AUTH_SESSION_UPDATE_AGE_SECONDS`, `OAUTH_AUTHORIZATION_CODE_TTL_SECONDS`, `OAUTH_ACCESS_TOKEN_TTL_SECONDS`, and `OAUTH_REFRESH_TOKEN_TTL_SECONDS`. Defaults preserve existing behavior. [#1162](https://github.com/sourcebot-dev/sourcebot/pull/1162) +- Guarded all OAuth authorization-server route handlers with a runtime assertion that rejects HTTP 307 and 308 responses, per RFC 9700 §4.12. [#1163](https://github.com/sourcebot-dev/sourcebot/pull/1163) ### Fixed - Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155)