diff --git a/apps/identity-service/src/oauth-http-application.ts b/apps/identity-service/src/oauth-http-application.ts new file mode 100644 index 00000000..d39a0606 --- /dev/null +++ b/apps/identity-service/src/oauth-http-application.ts @@ -0,0 +1,160 @@ +import { + OAuthTransactionService, + SessionService, + type ActiveSession, +} from './auth-security'; +import type { IdentityProvider } from './identity-domain'; +import { + APPLICATION_SESSION_COOKIE_NAME, + OAUTH_BROWSER_COOKIE_NAME, + buildFixedWebRedirect, + clearApplicationSessionCookie, + createOAuthBrowserBinding, + readOpaqueCookie, + toSessionView, +} from './oauth-http-boundary'; +import { buildAuthorizationUrl } from './oauth-provider'; +import { requireSafeRedirectUri } from './oauth-redirect-uri'; + +export interface OAuthProviderStartConfiguration { + clientId: string; + redirectUri: string; +} + +export interface OAuthHttpApplicationConfiguration { + providers: Readonly< + Record + >; + webOrigin: string; +} + +export interface AuthorizationStartResponse { + statusCode: 303; + location: string; + setCookie?: string; +} + +export interface SessionIntrospectionResponse { + statusCode: 200; + body: ReturnType; +} + +export interface LogoutResponse { + statusCode: 204; + setCookie: string; +} + +function requireProviderConfiguration( + provider: IdentityProvider, + configuration: OAuthHttpApplicationConfiguration, +): OAuthProviderStartConfiguration { + const providerConfiguration = configuration.providers[provider]; + if (!providerConfiguration) { + throw new Error('OAuth provider is not supported'); + } + const clientId = providerConfiguration.clientId.trim(); + if (!clientId) { + throw new Error('OAuth client ID is required'); + } + return { + clientId, + redirectUri: requireSafeRedirectUri(providerConfiguration.redirectUri), + }; +} + +/** + * Coordinates the browser-facing authorization start, session lookup, and logout boundaries. + * Provider callbacks are intentionally delegated to the subsequent callback-orchestration slice. + */ +export class OAuthHttpApplication { + private readonly fixedWebRedirect: string; + + constructor( + private readonly transactions: OAuthTransactionService, + private readonly sessions: SessionService, + private readonly configuration: OAuthHttpApplicationConfiguration, + ) { + this.fixedWebRedirect = buildFixedWebRedirect(configuration.webOrigin); + requireProviderConfiguration('google', configuration); + requireProviderConfiguration('github', configuration); + } + + /** + * Starts a provider-bound OAuth transaction and creates a browser-binding cookie when absent. + */ + async beginAuthorization( + provider: IdentityProvider, + cookieHeader: string | undefined, + ): Promise { + const existingBrowserSessionId = readOpaqueCookie( + cookieHeader, + OAUTH_BROWSER_COOKIE_NAME, + ); + const browserBinding = existingBrowserSessionId + ? { browserSessionId: existingBrowserSessionId } + : createOAuthBrowserBinding(); + const providerConfiguration = requireProviderConfiguration( + provider, + this.configuration, + ); + const transaction = await this.transactions.begin(provider, { + browserSessionId: browserBinding.browserSessionId, + redirectUri: providerConfiguration.redirectUri, + }); + + return { + statusCode: 303, + location: buildAuthorizationUrl( + provider, + providerConfiguration, + transaction, + ), + ...('setCookie' in browserBinding + ? { setCookie: browserBinding.setCookie } + : {}), + }; + } + + /** + * Authenticates the opaque server-side session cookie and returns no bearer material. + */ + async introspectSession( + cookieHeader: string | undefined, + ): Promise { + const token = readOpaqueCookie( + cookieHeader, + APPLICATION_SESSION_COOKIE_NAME, + ); + const session = await this.sessions.authenticate(token ?? ''); + return { statusCode: 200, body: toSessionView(session) }; + } + + /** + * Revokes an existing session when present and always returns an idempotent cookie clear. + */ + async logout(cookieHeader: string | undefined): Promise { + const token = readOpaqueCookie( + cookieHeader, + APPLICATION_SESSION_COOKIE_NAME, + ); + await this.sessions.revoke(token ?? ''); + return { + statusCode: 204, + setCookie: clearApplicationSessionCookie(), + }; + } + + /** + * Returns the configured fixed browser destination used after successful provider callbacks. + */ + postLoginRedirect(): string { + return this.fixedWebRedirect; + } + + /** + * Converts a session to the public response shape for callback orchestration. + */ + sessionView(session: ActiveSession): ReturnType { + return toSessionView(session); + } +} diff --git a/apps/identity-service/src/oauth-http-boundary.test.ts b/apps/identity-service/src/oauth-http-boundary.test.ts new file mode 100644 index 00000000..652e05e7 --- /dev/null +++ b/apps/identity-service/src/oauth-http-boundary.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest'; +import { + InMemoryOAuthTransactionRepository, + InMemorySessionRepository, + OAuthTransactionService, + SessionService, +} from './auth-security'; +import { OAuthHttpApplication } from './oauth-http-application'; +import { + APPLICATION_SESSION_COOKIE_NAME, + OAUTH_BROWSER_COOKIE_NAME, + buildFixedWebRedirect, + clearApplicationSessionCookie, + createOAuthBrowserBinding, + parseCookieHeader, + parseOAuthCallbackQuery, + problemDetails, + readOpaqueCookie, + serializeApplicationSessionCookie, + serializeSecureCookie, + toSessionView, +} from './oauth-http-boundary'; + +const USER_ID = '123e4567-e89b-42d3-a456-426614174000'; +const WORKSPACE_ID = '123e4567-e89b-42d3-b456-426614174001'; +const NOW = new Date('2026-08-03T10:00:00.000Z'); + +function application(): { + application: OAuthHttpApplication; + sessions: SessionService; +} { + const transactions = new OAuthTransactionService( + new InMemoryOAuthTransactionRepository(), + { now: () => NOW }, + ); + const sessions = new SessionService(new InMemorySessionRepository(), { + now: () => NOW, + ttlMs: 60 * 60 * 1000, + }); + return { + application: new OAuthHttpApplication(transactions, sessions, { + providers: { + google: { + clientId: 'google-client', + redirectUri: 'https://identity.example.com/v1/auth/google/callback', + }, + github: { + clientId: 'github-client', + redirectUri: 'https://identity.example.com/v1/auth/github/callback', + }, + }, + webOrigin: 'https://life.example.com', + }), + sessions, + }; +} + +describe('OAuth HTTP cookie boundary', () => { + it('parses opaque cookies without decoding and rejects duplicate or malformed input', () => { + expect(parseCookieHeader('alpha=one_two; beta=three-four')).toEqual({ + alpha: 'one_two', + beta: 'three-four', + }); + expect(readOpaqueCookie('alpha=one_two', 'alpha')).toBe('one_two'); + expect( + readOpaqueCookie( + `AWSALB=route%2Fabc; ${APPLICATION_SESSION_COOKIE_NAME}=opaque_session; cf_clearance=token.with.dots; gateway=a=b`, + APPLICATION_SESSION_COOKIE_NAME, + ), + ).toBe('opaque_session'); + const prototypeNamedCookies = parseCookieHeader( + 'constructor=one; __proto__=two; toString=three', + ); + expect(prototypeNamedCookies.constructor).toBe('one'); + expect(prototypeNamedCookies.__proto__).toBe('two'); + expect(prototypeNamedCookies.toString).toBe('three'); + expect(() => + readOpaqueCookie( + `${APPLICATION_SESSION_COOKIE_NAME}=token.with.dot`, + APPLICATION_SESSION_COOKIE_NAME, + ), + ).toThrow('Cookie header is invalid'); + expect(() => parseCookieHeader('alpha=one; alpha=two')).toThrow( + 'Cookie header is invalid', + ); + expect(() => parseCookieHeader('alpha="quoted"')).toThrow( + 'Cookie header is invalid', + ); + expect(() => parseCookieHeader(`alpha=${'a'.repeat(4097)}`)).toThrow( + 'Cookie header is invalid', + ); + }); + + it('creates a browser binding with strict cookie attributes and auth-only path', () => { + const binding = createOAuthBrowserBinding(); + expect(binding.browserSessionId).toMatch(/^[A-Za-z0-9_-]+$/); + expect(binding.setCookie).toBe( + `${OAUTH_BROWSER_COOKIE_NAME}=${binding.browserSessionId}; Path=/v1/auth; Max-Age=600; HttpOnly; Secure; SameSite=Lax`, + ); + }); + + it('serializes and clears secure application session cookies', () => { + expect( + serializeApplicationSessionCookie( + 'opaque_session', + '2026-08-03T11:00:00.000Z', + NOW, + ), + ).toBe( + `${APPLICATION_SESSION_COOKIE_NAME}=opaque_session; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax`, + ); + expect(clearApplicationSessionCookie()).toBe( + `${APPLICATION_SESSION_COOKIE_NAME}=deleted; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`, + ); + expect(() => + serializeSecureCookie({ + name: APPLICATION_SESSION_COOKIE_NAME, + value: 'not allowed', + maxAgeSeconds: 10, + }), + ).toThrow('Cookie value is invalid'); + expect(() => + serializeSecureCookie({ + name: APPLICATION_SESSION_COOKIE_NAME, + value: 'opaque_session', + maxAgeSeconds: 10, + path: '/safe\tpath', + }), + ).toThrow('Cookie path is invalid'); + }); +}); + +describe('OAuth callback query boundary', () => { + it('accepts one code and state without retaining unrelated provider data', () => { + expect( + parseOAuthCallbackQuery({ code: 'code_value', state: 'state_value' }), + ).toEqual({ + outcome: 'authorization_code', + code: 'code_value', + state: 'state_value', + }); + }); + + it('accepts bounded provider errors and rejects ambiguous, repeated, or unknown input', () => { + expect( + parseOAuthCallbackQuery({ + error: 'access_denied', + error_description: 'User cancelled', + state: 'state_value', + }), + ).toEqual({ + outcome: 'provider_error', + error: 'access_denied', + errorDescription: 'User cancelled', + state: 'state_value', + }); + expect(() => + parseOAuthCallbackQuery({ code: ['one', 'two'], state: 'state_value' }), + ).toThrow('must appear once'); + expect(() => + parseOAuthCallbackQuery({ + code: 'code', + error: 'denied', + state: 'state', + }), + ).toThrow('OAuth callback is invalid'); + expect(() => + parseOAuthCallbackQuery({ + code: 'code', + state: 'state', + return_to: 'https://evil.test', + }), + ).toThrow('unsupported parameter'); + }); +}); + +describe('OAuthHttpApplication', () => { + it('starts Google authorization with PKCE, nonce, fixed redirect, and a new binding cookie', async () => { + const { application: httpApplication } = application(); + const response = await httpApplication.beginAuthorization( + 'google', + undefined, + ); + const location = new URL(response.location); + + expect(response.statusCode).toBe(303); + expect(response.setCookie).toContain(`${OAUTH_BROWSER_COOKIE_NAME}=`); + expect(location.origin).toBe('https://accounts.google.com'); + expect(location.searchParams.get('client_id')).toBe('google-client'); + expect(location.searchParams.get('redirect_uri')).toBe( + 'https://identity.example.com/v1/auth/google/callback', + ); + expect(location.searchParams.get('code_challenge_method')).toBe('S256'); + expect(location.searchParams.get('code_challenge')).toMatch( + /^[A-Za-z0-9_-]+$/, + ); + expect(location.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]+$/); + expect(location.searchParams.get('nonce')).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('reuses an existing browser binding without rotating it during authorization start', async () => { + const { application: httpApplication } = application(); + const response = await httpApplication.beginAuthorization( + 'github', + `cf_clearance=token.with.dots; ${OAUTH_BROWSER_COOKIE_NAME}=existing_binding; gateway=a=b`, + ); + const location = new URL(response.location); + + expect(response.setCookie).toBeUndefined(); + expect(location.origin).toBe('https://github.com'); + expect(location.searchParams.get('nonce')).toBeNull(); + }); + + it('rejects unsupported providers with a handled domain error', async () => { + const { application: httpApplication } = application(); + await expect( + httpApplication.beginAuthorization('microsoft' as never, undefined), + ).rejects.toThrow('OAuth provider is not supported'); + }); + + it('introspects a server-backed session without returning its bearer token', async () => { + const { application: httpApplication, sessions } = application(); + const issued = await sessions.create(USER_ID, WORKSPACE_ID); + const response = await httpApplication.introspectSession( + `${APPLICATION_SESSION_COOKIE_NAME}=${issued.token}`, + ); + + expect(response).toEqual({ + statusCode: 200, + body: toSessionView(issued.session), + }); + expect(JSON.stringify(response)).not.toContain(issued.token); + }); + + it('revokes sessions and clears the cookie idempotently', async () => { + const { application: httpApplication, sessions } = application(); + const issued = await sessions.create(USER_ID, WORKSPACE_ID); + const cookie = `${APPLICATION_SESSION_COOKIE_NAME}=${issued.token}`; + + expect(await httpApplication.logout(cookie)).toEqual({ + statusCode: 204, + setCookie: clearApplicationSessionCookie(), + }); + await expect(sessions.authenticate(issued.token)).rejects.toThrow( + 'Session is invalid', + ); + await expect(httpApplication.logout(cookie)).resolves.toEqual({ + statusCode: 204, + setCookie: clearApplicationSessionCookie(), + }); + await expect(httpApplication.logout(undefined)).resolves.toEqual({ + statusCode: 204, + setCookie: clearApplicationSessionCookie(), + }); + }); + + it('uses one configured fixed post-login target and rejects unsafe origins', () => { + const { application: httpApplication } = application(); + expect(httpApplication.postLoginRedirect()).toBe( + 'https://life.example.com/auth/complete', + ); + expect(buildFixedWebRedirect('https://life.example.com')).toBe( + 'https://life.example.com/auth/complete', + ); + expect(() => buildFixedWebRedirect('http://life.example.com')).toThrow( + 'Configured web origin is invalid', + ); + expect(() => + buildFixedWebRedirect('https://life.example.com/other'), + ).toThrow('Configured web origin is invalid'); + }); +}); + +describe('problemDetails', () => { + it('creates a stable credential-free RFC 9457-compatible body', () => { + expect( + problemDetails(401, 'Authentication required', 'session_invalid'), + ).toEqual({ + type: 'about:blank', + title: 'Authentication required', + status: 401, + code: 'session_invalid', + }); + expect(() => problemDetails(200, 'No problem', 'invalid')).toThrow( + 'Problem status is invalid', + ); + }); +}); diff --git a/apps/identity-service/src/oauth-http-boundary.ts b/apps/identity-service/src/oauth-http-boundary.ts new file mode 100644 index 00000000..0bc9109c --- /dev/null +++ b/apps/identity-service/src/oauth-http-boundary.ts @@ -0,0 +1,342 @@ +import { randomBytes } from 'node:crypto'; +import type { ActiveSession } from './auth-security'; + +const COOKIE_HEADER_LIMIT_BYTES = 4 * 1024; +const QUERY_VALUE_LIMIT_BYTES = 2 * 1024; +const COOKIE_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const OPAQUE_COOKIE_VALUE_PATTERN = /^[A-Za-z0-9_-]+$/; +const RFC6265_COOKIE_VALUE_PATTERN = + /^[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*$/; +const COOKIE_PATH_PATTERN = /^\/[\x20-\x3a\x3c-\x7e]*$/; +const CALLBACK_QUERY_KEYS = new Set([ + 'code', + 'state', + 'error', + 'error_description', + 'error_uri', +]); + +export const OAUTH_BROWSER_COOKIE_NAME = 'life_os_oauth_browser'; +export const APPLICATION_SESSION_COOKIE_NAME = 'life_os_session'; + +export interface ProblemDetails { + type: 'about:blank'; + title: string; + status: number; + code: string; +} + +export type OAuthCallbackQuery = + | { + outcome: 'authorization_code'; + code: string; + state: string; + } + | { + outcome: 'provider_error'; + error: string; + state: string; + errorDescription?: string; + errorUri?: string; + }; + +function failInvalidCookie(): never { + throw new Error('Cookie header is invalid'); +} + +function requireBoundedText(value: unknown, fieldName: string): string { + if ( + typeof value !== 'string' || + !value.trim() || + Buffer.byteLength(value, 'utf8') > QUERY_VALUE_LIMIT_BYTES || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error(`${fieldName} is invalid`); + } + return value.trim(); +} + +function optionalSingleQueryValue( + query: Readonly>, + key: string, +): string | undefined { + const value = query[key]; + if (value === undefined) { + return undefined; + } + if (Array.isArray(value)) { + throw new Error(`OAuth callback parameter ${key} must appear once`); + } + return requireBoundedText(value, `OAuth callback parameter ${key}`); +} + +function requireCookieName(value: string): string { + if (!COOKIE_NAME_PATTERN.test(value)) { + throw new Error('Cookie name is invalid'); + } + return value; +} + +function requireOpaqueCookieValue(value: string): string { + if (!OPAQUE_COOKIE_VALUE_PATTERN.test(value)) { + throw new Error('Cookie value is invalid'); + } + return value; +} + +function requirePositiveInteger(value: number, message: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(message); + } + return value; +} + +/** + * Parses a bounded Cookie header without decoding or accepting duplicate names. + */ +export function parseCookieHeader( + header: string | undefined, +): Readonly> { + if (header === undefined || header === '') { + return Object.freeze({}); + } + if ( + typeof header !== 'string' || + Buffer.byteLength(header, 'utf8') > COOKIE_HEADER_LIMIT_BYTES || + /[\r\n\u0000]/.test(header) + ) { + return failInvalidCookie(); + } + + const cookies: Record = Object.create(null); + for (const segment of header.split(';')) { + const separator = segment.indexOf('='); + if (separator <= 0) { + return failInvalidCookie(); + } + const name = requireCookieName(segment.slice(0, separator).trim()); + const value = segment.slice(separator + 1).trim(); + if ( + !value || + !OPAQUE_COOKIE_VALUE_PATTERN.test(value) || + Object.prototype.hasOwnProperty.call(cookies, name) + ) { + return failInvalidCookie(); + } + cookies[name] = value; + } + return Object.freeze({ ...cookies }); +} + +/** + * Returns one opaque cookie value, or undefined when the cookie is absent. + */ +export function readOpaqueCookie( + header: string | undefined, + nameValue: string, +): string | undefined { + const name = requireCookieName(nameValue); + if (header === undefined || header === '') { + return undefined; + } + if ( + typeof header !== 'string' || + Buffer.byteLength(header, 'utf8') > COOKIE_HEADER_LIMIT_BYTES || + /[\r\n\u0000]/.test(header) + ) { + return failInvalidCookie(); + } + + let targetValue: string | undefined; + for (const segment of header.split(';')) { + const separator = segment.indexOf('='); + if (separator <= 0) { + return failInvalidCookie(); + } + const segmentName = requireCookieName(segment.slice(0, separator).trim()); + const value = segment.slice(separator + 1).trim(); + if (!RFC6265_COOKIE_VALUE_PATTERN.test(value)) { + return failInvalidCookie(); + } + if (segmentName !== name) { + continue; + } + if ( + !value || + !OPAQUE_COOKIE_VALUE_PATTERN.test(value) || + targetValue !== undefined + ) { + return failInvalidCookie(); + } + targetValue = value; + } + return targetValue; +} + +/** + * Parses the deliberately small OAuth callback query surface and rejects repeats or unknown keys. + */ +export function parseOAuthCallbackQuery( + query: Readonly>, +): OAuthCallbackQuery { + for (const key of Object.keys(query)) { + if (!CALLBACK_QUERY_KEYS.has(key)) { + throw new Error('OAuth callback contains an unsupported parameter'); + } + } + + const code = optionalSingleQueryValue(query, 'code'); + const state = optionalSingleQueryValue(query, 'state'); + const error = optionalSingleQueryValue(query, 'error'); + const errorDescription = optionalSingleQueryValue(query, 'error_description'); + const errorUri = optionalSingleQueryValue(query, 'error_uri'); + + if (!state || Boolean(code) === Boolean(error)) { + throw new Error('OAuth callback is invalid'); + } + if (code) { + if (errorDescription || errorUri) { + throw new Error('OAuth callback is invalid'); + } + return { outcome: 'authorization_code', code, state }; + } + return { + outcome: 'provider_error', + error: error as string, + state, + ...(errorDescription ? { errorDescription } : {}), + ...(errorUri ? { errorUri } : {}), + }; +} + +/** + * Serializes an opaque cookie with the security attributes required by the browser boundary. + */ +export function serializeSecureCookie(input: { + name: string; + value: string; + maxAgeSeconds: number; + path?: string; +}): string { + const name = requireCookieName(input.name); + const value = requireOpaqueCookieValue(input.value); + const maxAgeSeconds = requirePositiveInteger( + input.maxAgeSeconds, + 'Cookie max age must be a positive integer', + ); + const path = input.path ?? '/'; + if (!COOKIE_PATH_PATTERN.test(path)) { + throw new Error('Cookie path is invalid'); + } + return `${name}=${value}; Path=${path}; Max-Age=${maxAgeSeconds}; HttpOnly; Secure; SameSite=Lax`; +} + +/** + * Creates a new opaque browser binding and its short-lived authorization cookie. + */ +export function createOAuthBrowserBinding(maxAgeSeconds = 10 * 60): { + browserSessionId: string; + setCookie: string; +} { + const browserSessionId = randomBytes(32).toString('base64url'); + return { + browserSessionId, + setCookie: serializeSecureCookie({ + name: OAUTH_BROWSER_COOKIE_NAME, + value: browserSessionId, + maxAgeSeconds, + path: '/v1/auth', + }), + }; +} + +/** + * Creates the secure, server-backed application session cookie. + */ +export function serializeApplicationSessionCookie( + token: string, + expiresAt: string, + now = new Date(), +): string { + const expiration = Date.parse(expiresAt); + if ( + !Number.isFinite(expiration) || + !Number.isFinite(now.getTime()) || + expiration <= now.getTime() + ) { + throw new Error('Session expiration is invalid'); + } + const maxAgeSeconds = Math.max( + 1, + Math.floor((expiration - now.getTime()) / 1000), + ); + return serializeSecureCookie({ + name: APPLICATION_SESSION_COOKIE_NAME, + value: token, + maxAgeSeconds, + path: '/', + }); +} + +/** + * Clears the browser session cookie without exposing its prior value. + */ +export function clearApplicationSessionCookie(): string { + return `${APPLICATION_SESSION_COOKIE_NAME}=deleted; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`; +} + +/** + * Accepts only an HTTPS origin and derives the fixed post-login target. + */ +export function buildFixedWebRedirect(configuredOrigin: string): string { + const parsed = new URL(configuredOrigin); + if ( + parsed.protocol !== 'https:' || + parsed.username || + parsed.password || + parsed.pathname !== '/' || + parsed.search || + parsed.hash + ) { + throw new Error('Configured web origin is invalid'); + } + return new URL('/auth/complete', parsed.origin).toString(); +} + +/** + * Produces a credential-free session representation for browser introspection. + */ +export function toSessionView(session: ActiveSession): { + sessionId: string; + userId: string; + workspaceId: string; + createdAt: string; + expiresAt: string; +} { + return { + sessionId: session.id, + userId: session.userId, + workspaceId: session.workspaceId, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + }; +} + +/** + * Produces a stable RFC 9457-compatible problem body without internal details. + */ +export function problemDetails( + status: number, + title: string, + code: string, +): ProblemDetails { + if (!Number.isSafeInteger(status) || status < 400 || status > 599) { + throw new Error('Problem status is invalid'); + } + return { + type: 'about:blank', + title: requireBoundedText(title, 'Problem title'), + status, + code: requireBoundedText(code, 'Problem code'), + }; +} diff --git a/docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md b/docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md new file mode 100644 index 00000000..c76d2eee --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md @@ -0,0 +1,26 @@ +# OAuth HTTP Boundary Slice + +## Goal + +Create the security-critical browser boundary that the Google and GitHub callback orchestration in issue #18 will use. This slice intentionally does not claim to complete provider token exchange, Google JWKS verification, account provisioning, or callback routing. + +## Included + +- bounded Cookie header parsing that rejects duplicate, quoted, malformed, or oversized opaque cookies +- secure browser-binding and application-session cookie serialization +- strict callback query parsing with one `code` or one provider `error`, one `state`, no repeated security-sensitive values, and no arbitrary redirect input +- a fixed HTTPS post-login destination derived only from configured web origin +- RFC 9457-compatible credential-free problem bodies +- authorization-start coordination using the existing state, PKCE, nonce, redirect-URI, and transaction persistence primitives +- session introspection that never returns the bearer token +- idempotent logout that revokes the server-side session before clearing the browser cookie + +## Verification + +- unit tests cover malformed and duplicate cookies, callback ambiguity, unknown callback parameters, browser-binding reuse, Google PKCE and nonce, GitHub authorization start, fixed redirect enforcement, token-free session introspection, revocation, and idempotent logout +- TypeScript compilation and the full repository test suite remain required +- CI, SAST Semgrep, Security Scan, AppGuardrail, Commercial Readiness, and review feedback must pass before merge + +## Follow-up + +The next slice should add production NestJS controllers and dependency wiring for PostgreSQL-backed transactions and sessions, then orchestrate fixed-endpoint token exchange, Google JWKS signature validation, GitHub identity retrieval, atomic account provisioning, secure session issuance, audit events, and generic callback error mapping. diff --git a/package.json b/package.json index 1e2b3490..d770f0ab 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "test": "turbo run test", "typecheck": "turbo run typecheck", - "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md", + "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md", "format": "prettier --single-quote --write ." }, "devDependencies": {