From 89c69e6e17973db83a37c94e4cc350cdd81c98ae Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 20:09:08 +0800 Subject: [PATCH 1/2] feat: add OAuth provider adapters --- packages/oauth/README.md | 177 ++++-------- packages/oauth/__tests__/adapters.test.ts | 236 ++++++++++++++++ packages/oauth/__tests__/http.test.ts | 91 ++++++ packages/oauth/__tests__/oauth-client.test.ts | 267 ------------------ packages/oauth/__tests__/primitives.test.ts | 102 +++++++ packages/oauth/jest.config.js | 1 + packages/oauth/package.json | 10 +- packages/oauth/src/adapter.ts | 25 ++ packages/oauth/src/authorization.ts | 86 ++++++ packages/oauth/src/endpoint.ts | 102 +++++++ packages/oauth/src/http.ts | 118 ++++++++ packages/oauth/src/index.ts | 59 ++-- packages/oauth/src/middleware/express.ts | 244 ---------------- packages/oauth/src/oauth-client.ts | 210 -------------- packages/oauth/src/primitives.ts | 35 +++ packages/oauth/src/providers/common.ts | 105 +++++++ packages/oauth/src/providers/facebook.ts | 35 --- packages/oauth/src/providers/github.ts | 241 +++++++++++++--- packages/oauth/src/providers/google.ts | 255 +++++++++++++++-- packages/oauth/src/providers/index.ts | 49 ++-- packages/oauth/src/providers/linkedin.ts | 32 --- packages/oauth/src/types.ts | 144 ++++++---- packages/oauth/src/utils/state.ts | 1 - pnpm-lock.yaml | 14 +- 24 files changed, 1553 insertions(+), 1086 deletions(-) create mode 100644 packages/oauth/__tests__/adapters.test.ts create mode 100644 packages/oauth/__tests__/http.test.ts delete mode 100644 packages/oauth/__tests__/oauth-client.test.ts create mode 100644 packages/oauth/__tests__/primitives.test.ts create mode 100644 packages/oauth/src/adapter.ts create mode 100644 packages/oauth/src/authorization.ts create mode 100644 packages/oauth/src/endpoint.ts create mode 100644 packages/oauth/src/http.ts delete mode 100644 packages/oauth/src/middleware/express.ts delete mode 100644 packages/oauth/src/oauth-client.ts create mode 100644 packages/oauth/src/primitives.ts create mode 100644 packages/oauth/src/providers/common.ts delete mode 100644 packages/oauth/src/providers/facebook.ts delete mode 100644 packages/oauth/src/providers/linkedin.ts delete mode 100644 packages/oauth/src/utils/state.ts diff --git a/packages/oauth/README.md b/packages/oauth/README.md index f8c6866041..e38b082639 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -1,128 +1,69 @@ # @constructive-io/oauth -

- -

- -

- - - - - - - - - -

- -> Minimal OAuth 2.0 client for social authentication - -A lightweight OAuth 2.0 client for social authentication with Google, GitHub, Facebook, and LinkedIn. Uses [`@constructive-io/csrf`](../csrf) for secure state management. No external auth library dependencies - uses native fetch for HTTP requests. - -## Installation - -```bash -pnpm add @constructive-io/oauth -``` - -## Usage - -### Basic Setup - -```typescript -import { createOAuthClient } from '@constructive-io/oauth'; - -const client = createOAuthClient({ - providers: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - }, - github: { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - }, - }, - baseUrl: 'https://api.example.com', -}); - -// Generate authorization URL -const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); - -// After user authorizes, exchange code for profile -const profile = await client.handleCallback({ provider: 'google', code }); -``` - -### Express Middleware - -```typescript -import express from 'express'; -import cookieParser from 'cookie-parser'; -import { createOAuthMiddleware } from '@constructive-io/oauth'; - -const app = express(); -app.use(cookieParser()); - -const oauth = createOAuthMiddleware({ - providers: { - google: { clientId: '...', clientSecret: '...' }, - github: { clientId: '...', clientSecret: '...' }, - facebook: { clientId: '...', clientSecret: '...' }, - linkedin: { clientId: '...', clientSecret: '...' }, - }, - baseUrl: 'https://api.example.com', - onSuccess: async (profile, context) => { - // Handle successful authentication - // Create/update user in database, generate session token, etc. - return { user: profile }; - }, - onError: (error, context) => { - console.error('OAuth error:', error); - }, - successRedirect: 'https://app.example.com/dashboard', - errorRedirect: 'https://app.example.com/login?error=auth_failed', +Protocol primitives and Provider adapters for Constructive OAuth/OIDC sign-in. + +The package owns: + +- cryptographically random OAuth state, OIDC nonce, and RFC 7636 S256 PKCE; +- authorization URL construction with protected parameters; +- exact Provider endpoint allowlists and bounded, no-redirect JSON requests; +- a protocol-neutral `ProviderAdapter` contract; +- registered Google/OIDC and GitHub/OAuth adapter implementations; and +- safe normalized external identities without raw Provider payloads or tokens. + +It deliberately does not own Express routes, Cookies, Tenant resolution, +database state, account association, Constructive credentials, or Site handoff. +Those remain in their Constructive orchestration owners. + +## Security model + +Every Provider flow uses Authorization Code with S256 PKCE. Constructive creates +and persists the state, verifier, and optional nonce before navigation. Only the +state and S256 challenge reach the browser. The callback supplies the code to +Constructive, and the selected adapter exchanges it with the original +server-held verifier. + +Provider configuration is supplied by the caller after Tenant-scoped loader +resolution. Adapters do not read environment variables or databases. Endpoints +must match the concrete adapter's HTTPS allowlist, requests reject redirects, +and Provider response bodies are never included in errors. + +## Example + +```ts +import { + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + getProviderAdapter +} from '@constructive-io/oauth'; + +const adapter = getProviderAdapter(provider.slug); +const config = adapter.validateConfiguration(provider); +const state = generateOpaqueState(); +const codeVerifier = generateCodeVerifier(); +const nonce = adapter.kind === 'google' ? generateOidcNonce() : undefined; + +const { url } = adapter.createAuthorizationRequest({ + config, + redirectUri, + state, + codeChallenge: deriveS256CodeChallenge(codeVerifier), + nonce }); - -// Mount routes -app.get('/auth/:provider', oauth.initiateAuth); -app.get('/auth/:provider/callback', oauth.handleCallback); -app.get('/auth/providers', oauth.getProviders); ``` -## Supported Providers +The common service persists the request artifacts before using `url`. It later +calls `completeAuthorization` with the callback code and server-held artifacts, +then consumes only the returned `NormalizedExternalIdentity`. -| Provider | Scopes | -|----------|--------| -| Google | `openid`, `email`, `profile` | -| GitHub | `user:email`, `read:user` | -| Facebook | `email`, `public_profile` | -| LinkedIn | `openid`, `profile`, `email` | +## Legacy surface -## API - -### `createOAuthClient(config)` - -Creates an OAuth client instance. - -### `createOAuthMiddleware(config)` - -Creates Express route handlers for OAuth flows. - -### `OAuthProfile` - -The normalized user profile returned after authentication: - -```typescript -interface OAuthProfile { - provider: string; // 'google', 'github', etc. - providerId: string; // Provider's unique user ID - email: string | null; - name: string | null; - picture: string | null; - raw: unknown; // Original provider response -} -``` +The previous hard-coded Provider registry, Express middleware, +`/auth/providers` discovery handler, browser state Cookie, and raw profile +payload are intentionally not part of this API. Provider discovery belongs to +Tenant-scoped GraphQL, and replay protection belongs to persisted server state. ## License diff --git a/packages/oauth/__tests__/adapters.test.ts b/packages/oauth/__tests__/adapters.test.ts new file mode 100644 index 0000000000..b9d65b6e74 --- /dev/null +++ b/packages/oauth/__tests__/adapters.test.ts @@ -0,0 +1,236 @@ +import { + exportJWK, + generateKeyPair, + type KeyLike, + SignJWT} from 'jose'; + +import { + getProviderAdapter, + getProviderAdapterKinds, + githubAdapter, + googleAdapter, + type IdentityProviderConfiguration, + ProviderAdapterError} from '../src'; + +const providerConfig = ( + overrides: Partial +): IdentityProviderConfiguration => ({ + slug: 'provider', + kind: 'oauth2', + displayName: 'Provider', + enabled: true, + clientId: 'client-id', + clientSecret: 'client-secret', + authorizationUrl: null, + tokenUrl: null, + userinfoUrl: null, + issuerUrl: null, + discoveryDoc: null, + jwks: null, + acceptableClientIds: [], + scopes: [], + extraAuthorizationParams: {}, + emailOptional: true, + skipNonceCheck: false, + pkceEnabled: true, + ...overrides +}); + +const jsonResponse = (value: unknown): Response => + new Response(JSON.stringify(value), { + headers: { 'content-type': 'application/json' } + }); + +describe('Provider adapter registry', () => { + it('registers Google and GitHub without a Provider-specific workflow API', () => { + expect(getProviderAdapterKinds()).toEqual(['google', 'github']); + expect(getProviderAdapter('google')).toBe(googleAdapter); + expect(getProviderAdapter('github')).toBe(githubAdapter); + expect(() => getProviderAdapter('not-registered')).toThrow( + ProviderAdapterError + ); + }); +}); + +describe('Google OIDC adapter', () => { + let privateKey: KeyLike; + let publicJwk: Awaited>; + + beforeAll(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + publicJwk = await exportJWK(pair.publicKey); + publicJwk.kid = 'test-key'; + publicJwk.alg = 'RS256'; + }); + + const googleConfig = (): IdentityProviderConfiguration => + providerConfig({ + slug: 'google', + kind: 'oidc', + displayName: 'Google', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + issuerUrl: 'https://accounts.google.com', + jwks: { keys: [publicJwk] }, + scopes: ['openid', 'email', 'profile'] + }); + + it('builds mandatory S256 and nonce authorization parameters', () => { + const config = googleAdapter.validateConfiguration(googleConfig()); + const url = new URL( + googleAdapter.createAuthorizationRequest({ + config, + redirectUri: 'https://auth.example.com/auth/oauth/callback', + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + nonce: 'n'.repeat(43) + }).url + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + code_challenge: 'c'.repeat(43), + code_challenge_method: 'S256', + nonce: 'n'.repeat(43), + state: 's'.repeat(43) + }); + }); + + it('verifies the ID token and returns only normalized identity data', async () => { + const idToken = await new SignJWT({ + email: 'person@example.com', + email_verified: false, + name: 'Example Person', + nonce: 'n'.repeat(43), + picture: 'https://images.example.com/person.png' + }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://accounts.google.com') + .setAudience('client-id') + .setSubject('google-subject') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse({ access_token: 'never-return-this', id_token: idToken }) + ) as unknown as jest.MockedFunction; + + const identity = await googleAdapter.completeAuthorization({ + config: googleAdapter.validateConfiguration(googleConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'a'.repeat(43), + nonce: 'n'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }); + + expect(identity).toEqual({ + providerKey: 'google', + subject: 'google-subject', + email: 'person@example.com', + profile: { + name: 'Example Person', + avatarUrl: 'https://images.example.com/person.png', + emailVerified: false + } + }); + expect(JSON.stringify(identity)).not.toContain('never-return-this'); + }); + + it('fails closed when PKCE or nonce verification is disabled', () => { + expect(() => + googleAdapter.validateConfiguration( + { ...googleConfig(), pkceEnabled: false } + ) + ).toThrow(ProviderAdapterError); + expect(() => + googleAdapter.validateConfiguration( + { ...googleConfig(), skipNonceCheck: true } + ) + ).toThrow(ProviderAdapterError); + }); + + it('rejects an ID token that is not bound to the original nonce', async () => { + const idToken = await new SignJWT({ nonce: 'different-nonce' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://accounts.google.com') + .setAudience('client-id') + .setSubject('google-subject') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse({ id_token: idToken }) + ) as unknown as jest.MockedFunction; + + await expect( + googleAdapter.completeAuthorization({ + config: googleAdapter.validateConfiguration(googleConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'a'.repeat(43), + nonce: 'n'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }) + ).rejects.toMatchObject({ reason: 'IDENTITY_VERIFICATION_FAILED' }); + }); +}); + +describe('GitHub OAuth adapter', () => { + const githubConfig = (): IdentityProviderConfiguration => + providerConfig({ + slug: 'github', + displayName: 'GitHub', + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userinfoUrl: 'https://api.github.com/user', + scopes: ['read:user', 'user:email'] + }); + + it('uses server-only access token for profile/email and normalizes stable ID', async () => { + const responses: unknown[] = [ + { access_token: 'github-server-token' }, + { + id: 42, + login: 'octocat', + name: 'Octo Cat', + email: null, + avatar_url: 'https://avatars.githubusercontent.com/u/42' + }, + [{ email: 'octo@example.com', primary: true, verified: true }] + ]; + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse(responses.shift()) + ) as unknown as jest.MockedFunction; + + const identity = await githubAdapter.completeAuthorization({ + config: githubAdapter.validateConfiguration(githubConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'b'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }); + + expect(identity).toEqual({ + providerKey: 'github', + subject: '42', + email: 'octo@example.com', + profile: { + name: 'Octo Cat', + username: 'octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/42', + emailVerified: true + } + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: 'Bearer github-server-token' + }); + expect(JSON.stringify(identity)).not.toContain('github-server-token'); + }); +}); diff --git a/packages/oauth/__tests__/http.test.ts b/packages/oauth/__tests__/http.test.ts new file mode 100644 index 0000000000..b66249d887 --- /dev/null +++ b/packages/oauth/__tests__/http.test.ts @@ -0,0 +1,91 @@ +import { + ProviderAdapterError, + requestProviderJson, + validateProviderEndpoint +} from '../src'; + +const endpoint = validateProviderEndpoint('https://api.example.com/token', [ + 'https://api.example.com/token' +]); + +describe('bounded Provider requests', () => { + it('uses no-redirect fetch and parses a bounded JSON response', async () => { + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' } + }) + ) as unknown as jest.MockedFunction; + + await expect( + requestProviderJson(endpoint, { method: 'POST' }, { + timeoutMs: 1000, + fetch: fetchMock + }) + ).resolves.toEqual({ ok: true }); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'POST', + redirect: 'error' + }); + }); + + it('does not expose an unsuccessful Provider response body', async () => { + const secretBody = 'provider-secret-response'; + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(secretBody, { + status: 400, + headers: { 'content-type': 'application/json' } + }) + ) as unknown as jest.MockedFunction; + + const error = (await requestProviderJson(endpoint, {}, { + timeoutMs: 1000, + fetch: fetchMock + }).catch(value => value)) as ProviderAdapterError; + expect(error).toBeInstanceOf(ProviderAdapterError); + expect(error.reason).toBe('INVALID_RESPONSE'); + expect(error.message).not.toContain(secretBody); + }); + + it('rejects oversized and non-JSON responses', async () => { + const oversized = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response('{}', { + headers: { + 'content-length': '70000', + 'content-type': 'application/json' + } + }) + ) as unknown as jest.MockedFunction; + await expect( + requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: oversized }) + ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' }); + + const wrongType = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response('{}', { headers: { 'content-type': 'text/plain' } }) + ) as unknown as jest.MockedFunction; + await expect( + requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: wrongType }) + ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' }); + }); + + it('classifies timeout without swallowing its cause', async () => { + const fetchMock = jest.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + }) + ) as unknown as jest.MockedFunction; + + const error = (await requestProviderJson(endpoint, {}, { + timeoutMs: 1, + fetch: fetchMock + }).catch(value => value)) as ProviderAdapterError; + expect(error).toMatchObject({ reason: 'REQUEST_TIMEOUT' }); + expect(error.cause).toBeInstanceOf(DOMException); + }); +}); diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts deleted file mode 100644 index 541192de35..0000000000 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { createOAuthClient } from '../src/oauth-client'; -import { getProvider, getProviderIds } from '../src/providers'; -import { generateState, verifyState } from '../src/utils/state'; - -describe('OAuthClient', () => { - const config = { - providers: { - google: { - clientId: 'test-google-client-id', - clientSecret: 'test-google-client-secret', - }, - github: { - clientId: 'test-github-client-id', - clientSecret: 'test-github-client-secret', - }, - }, - baseUrl: 'https://api.example.com', - }; - - describe('getAuthorizationUrl', () => { - it('should generate authorization URL for Google', () => { - const client = createOAuthClient(config); - const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); - - expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth'); - expect(url).toContain('client_id=test-google-client-id'); - expect(url).toContain('redirect_uri='); - expect(url).toContain('response_type=code'); - expect(url).toContain('scope=openid+email+profile'); - expect(url).toContain(`state=${state}`); - expect(state).toHaveLength(64); - }); - - it('should generate authorization URL for GitHub', () => { - const client = createOAuthClient(config); - const { url, state } = client.getAuthorizationUrl({ provider: 'github' }); - - expect(url).toContain('https://github.com/login/oauth/authorize'); - expect(url).toContain('client_id=test-github-client-id'); - expect(url).toContain('scope=user%3Aemail+read%3Auser'); - expect(state).toHaveLength(64); - }); - - it('should use custom state when provided', () => { - const client = createOAuthClient(config); - const customState = 'my-custom-state-123'; - const { url, state } = client.getAuthorizationUrl({ - provider: 'google', - state: customState, - }); - - expect(state).toBe(customState); - expect(url).toContain(`state=${customState}`); - }); - - it('should use custom redirect URI when provided', () => { - const client = createOAuthClient(config); - const customRedirectUri = 'https://custom.example.com/callback'; - const { url } = client.getAuthorizationUrl({ - provider: 'google', - redirectUri: customRedirectUri, - }); - - expect(url).toContain(`redirect_uri=${encodeURIComponent(customRedirectUri)}`); - }); - - it('should use custom scopes when provided', () => { - const client = createOAuthClient(config); - const { url } = client.getAuthorizationUrl({ - provider: 'google', - scopes: ['email'], - }); - - expect(url).toContain('scope=email'); - expect(url).not.toContain('profile'); - }); - - it('should throw error for unknown provider', () => { - const client = createOAuthClient(config); - - expect(() => { - client.getAuthorizationUrl({ provider: 'unknown' }); - }).toThrow('Unknown provider: unknown'); - }); - - it('should throw error for unconfigured provider', () => { - const client = createOAuthClient(config); - - expect(() => { - client.getAuthorizationUrl({ provider: 'facebook' }); - }).toThrow('No credentials configured for provider: facebook'); - }); - }); - - describe('getConfig', () => { - it('should return config with defaults', () => { - const client = createOAuthClient(config); - const returnedConfig = client.getConfig(); - - expect(returnedConfig.callbackPath).toBe('/auth/{provider}/callback'); - expect(returnedConfig.stateCookieName).toBe('oauth_state'); - expect(returnedConfig.stateCookieMaxAge).toBe(600); - }); - - it('should allow overriding defaults', () => { - const client = createOAuthClient({ - ...config, - callbackPath: '/custom/callback/{provider}', - stateCookieName: 'custom_state', - stateCookieMaxAge: 300, - }); - const returnedConfig = client.getConfig(); - - expect(returnedConfig.callbackPath).toBe('/custom/callback/{provider}'); - expect(returnedConfig.stateCookieName).toBe('custom_state'); - expect(returnedConfig.stateCookieMaxAge).toBe(300); - }); - }); -}); - -describe('providers', () => { - it('should have all expected providers', () => { - const ids = getProviderIds(); - expect(ids).toContain('google'); - expect(ids).toContain('github'); - expect(ids).toContain('facebook'); - expect(ids).toContain('linkedin'); - }); - - it('should return provider config by id', () => { - const google = getProvider('google'); - expect(google).toBeDefined(); - expect(google!.id).toBe('google'); - expect(google!.name).toBe('Google'); - expect(google!.authorizationUrl).toBe('https://accounts.google.com/o/oauth2/v2/auth'); - }); - - it('should return undefined for unknown provider', () => { - const unknown = getProvider('unknown'); - expect(unknown).toBeUndefined(); - }); -}); - -describe('state utilities', () => { - describe('generateState', () => { - it('should generate random state of default length', () => { - const state = generateState(); - expect(state).toHaveLength(64); - }); - - it('should generate random state of custom length', () => { - const state = generateState(16); - expect(state).toHaveLength(32); - }); - - it('should generate unique states', () => { - const state1 = generateState(); - const state2 = generateState(); - expect(state1).not.toBe(state2); - }); - }); - - describe('verifyState', () => { - it('should return true for matching states', () => { - const state = generateState(); - expect(verifyState(state, state)).toBe(true); - }); - - it('should return false for non-matching states', () => { - const state1 = generateState(); - const state2 = generateState(); - expect(verifyState(state1, state2)).toBe(false); - }); - - it('should return false for undefined expected state', () => { - expect(verifyState(undefined, 'some-state')).toBe(false); - }); - - it('should return false for undefined actual state', () => { - expect(verifyState('some-state', undefined)).toBe(false); - }); - - it('should return false for different length states', () => { - expect(verifyState('short', 'much-longer-state')).toBe(false); - }); - }); -}); - -describe('provider profile mapping', () => { - it('should map Google profile correctly', () => { - const google = getProvider('google')!; - const profile = google.mapProfile({ - sub: '123456789', - email: 'test@gmail.com', - name: 'Test User', - picture: 'https://example.com/photo.jpg', - }); - - expect(profile.provider).toBe('google'); - expect(profile.providerId).toBe('123456789'); - expect(profile.email).toBe('test@gmail.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/photo.jpg'); - }); - - it('should map GitHub profile correctly', () => { - const github = getProvider('github')!; - const profile = github.mapProfile({ - id: 12345, - login: 'testuser', - name: 'Test User', - email: 'test@github.com', - avatar_url: 'https://avatars.githubusercontent.com/u/12345', - }); - - expect(profile.provider).toBe('github'); - expect(profile.providerId).toBe('12345'); - expect(profile.email).toBe('test@github.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://avatars.githubusercontent.com/u/12345'); - }); - - it('should map Facebook profile correctly', () => { - const facebook = getProvider('facebook')!; - const profile = facebook.mapProfile({ - id: '987654321', - name: 'Test User', - email: 'test@facebook.com', - picture: { data: { url: 'https://example.com/fb-photo.jpg' } }, - }); - - expect(profile.provider).toBe('facebook'); - expect(profile.providerId).toBe('987654321'); - expect(profile.email).toBe('test@facebook.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/fb-photo.jpg'); - }); - - it('should map LinkedIn profile correctly', () => { - const linkedin = getProvider('linkedin')!; - const profile = linkedin.mapProfile({ - sub: 'linkedin-123', - email: 'test@linkedin.com', - name: 'Test User', - picture: 'https://example.com/li-photo.jpg', - }); - - expect(profile.provider).toBe('linkedin'); - expect(profile.providerId).toBe('linkedin-123'); - expect(profile.email).toBe('test@linkedin.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/li-photo.jpg'); - }); - - it('should handle missing optional fields', () => { - const google = getProvider('google')!; - const profile = google.mapProfile({ - sub: '123456789', - }); - - expect(profile.provider).toBe('google'); - expect(profile.providerId).toBe('123456789'); - expect(profile.email).toBeNull(); - expect(profile.name).toBeNull(); - expect(profile.picture).toBeNull(); - }); -}); diff --git a/packages/oauth/__tests__/primitives.test.ts b/packages/oauth/__tests__/primitives.test.ts new file mode 100644 index 0000000000..7af99eab24 --- /dev/null +++ b/packages/oauth/__tests__/primitives.test.ts @@ -0,0 +1,102 @@ +import { + createAuthorizationUrl, + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + isOpaqueOAuthValue, + ProviderAdapterError, + validateProviderEndpoint +} from '../src'; + +describe('OAuth protocol primitives', () => { + it('generates unique 32-byte browser-safe values', () => { + const values = [ + generateOpaqueState(), + generateOpaqueState(), + generateCodeVerifier(), + generateOidcNonce() + ]; + expect(new Set(values).size).toBe(values.length); + for (const value of values) { + expect(value).toHaveLength(43); + expect(isOpaqueOAuthValue(value)).toBe(true); + expect(value).toMatch(/^[A-Za-z0-9_-]+$/); + } + }); + + it('derives the RFC 7636 S256 example challenge', () => { + expect( + deriveS256CodeChallenge( + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + ) + ).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + + it('rejects a verifier outside the RFC 7636 shape', () => { + expect(() => deriveS256CodeChallenge('too-short')).toThrow( + ProviderAdapterError + ); + }); +}); + +describe('Provider endpoint and authorization URL safety', () => { + const endpoint = validateProviderEndpoint( + 'https://accounts.example.com/oauth/authorize', + ['https://accounts.example.com/oauth/authorize'] + ); + + it('requires an exact, clean, allowlisted HTTPS endpoint', () => { + for (const unsafe of [ + 'http://accounts.example.com/oauth/authorize', + 'https://accounts.example.com/oauth/authorize?next=unsafe', + 'https://user:secret@accounts.example.com/oauth/authorize', + 'https://127.0.0.1/oauth/authorize', + 'https://[::1]/oauth/authorize', + 'https://accounts.example.com/other' + ]) { + expect(() => + validateProviderEndpoint(unsafe, [ + 'https://accounts.example.com/oauth/authorize' + ]) + ).toThrow(ProviderAdapterError); + } + }); + + it('owns all security-sensitive authorization parameters', () => { + expect(() => + createAuthorizationUrl({ + endpoint, + clientId: 'client-id', + redirectUri: 'https://auth.example.com/auth/oauth/callback', + scopes: ['openid'], + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + extraParameters: { prompt: 'select_account', state: 'overridden' } + }) + ).toThrow(/owned by the OAuth flow/); + + const url = new URL( + createAuthorizationUrl({ + endpoint, + clientId: 'client-id', + redirectUri: 'https://auth.example.com/auth/oauth/callback', + scopes: ['openid', 'email'], + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + nonce: 'n'.repeat(43), + extraParameters: { prompt: 'select_account' } + }) + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: 'client-id', + code_challenge: 'c'.repeat(43), + code_challenge_method: 'S256', + nonce: 'n'.repeat(43), + prompt: 'select_account', + response_type: 'code', + scope: 'openid email', + state: 's'.repeat(43) + }); + }); +}); diff --git a/packages/oauth/jest.config.js b/packages/oauth/jest.config.js index 047b2ae4ee..610b872561 100644 --- a/packages/oauth/jest.config.js +++ b/packages/oauth/jest.config.js @@ -4,5 +4,6 @@ module.exports = { '^.+\\.tsx?$': ['ts-jest', { useESM: false }], }, testMatch: ['**/__tests__/**/*.test.ts'], + modulePathIgnorePatterns: ['/dist/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], }; diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 2cfd50f3c0..7ddb16fa0f 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -2,7 +2,7 @@ "name": "@constructive-io/oauth", "version": "0.27.0", "author": "Constructive ", - "description": "OAuth 2.0 client for social authentication (Google, GitHub, Facebook, LinkedIn)", + "description": "OAuth/OIDC protocol primitives and Provider adapters for Constructive authentication", "main": "index.js", "module": "esm/index.js", "types": "index.d.ts", @@ -29,7 +29,7 @@ "test:watch": "jest --watch" }, "dependencies": { - "@constructive-io/csrf": "workspace:^" + "jose": "^5.10.0" }, "devDependencies": { "@types/node": "^22.19.11", @@ -42,9 +42,9 @@ "authentication", "google", "github", - "facebook", - "linkedin", - "social-login", + "oidc", + "pkce", + "provider-adapter", "constructive" ] } diff --git a/packages/oauth/src/adapter.ts b/packages/oauth/src/adapter.ts new file mode 100644 index 0000000000..c61bccdea1 --- /dev/null +++ b/packages/oauth/src/adapter.ts @@ -0,0 +1,25 @@ +import type { + IdentityProviderConfiguration, + NormalizedExternalIdentity, + ProviderAuthorizationInput, + ProviderAuthorizationResult, + ProviderCallbackInput, + ValidatedProviderConfiguration +} from './types'; + +/** + * Protocol-neutral Provider boundary. Common login orchestration remains + * outside adapters and no inheritance hierarchy is required. + */ +export interface ProviderAdapter< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + readonly kind: string; + validateConfiguration(input: IdentityProviderConfiguration): C; + createAuthorizationRequest( + input: ProviderAuthorizationInput + ): ProviderAuthorizationResult; + completeAuthorization( + input: ProviderCallbackInput + ): Promise; +} diff --git a/packages/oauth/src/authorization.ts b/packages/oauth/src/authorization.ts new file mode 100644 index 0000000000..a85e10fac6 --- /dev/null +++ b/packages/oauth/src/authorization.ts @@ -0,0 +1,86 @@ +import { isOpaqueOAuthValue } from './primitives'; +import type { ValidatedEndpoint } from './types'; +import { ProviderAdapterError } from './types'; + +const PROTECTED_PARAMETERS = new Set([ + 'client_id', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'redirect_uri', + 'response_type', + 'scope', + 'state' +]); + +export interface AuthorizationUrlInput { + endpoint: ValidatedEndpoint; + clientId: string; + redirectUri: string; + scopes: readonly string[]; + state: string; + codeChallenge: string; + nonce?: string; + extraParameters?: Readonly>; +} + +export const validateProviderCallbackUri = (value: string): string => { + let redirectUri: URL; + try { + redirectUri = new URL(value); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider callback URI is invalid.', + { cause } + ); + } + if ( + redirectUri.protocol !== 'https:' || + redirectUri.username || + redirectUri.password || + redirectUri.hash + ) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider callback URI is invalid.' + ); + } + return redirectUri.toString(); +}; + +export const createAuthorizationUrl = (input: AuthorizationUrlInput): string => { + const redirectUri = validateProviderCallbackUri(input.redirectUri); + if ( + !isOpaqueOAuthValue(input.state) || + !isOpaqueOAuthValue(input.codeChallenge) || + (input.nonce !== undefined && !isOpaqueOAuthValue(input.nonce)) + ) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider authorization input is invalid.' + ); + } + + const url = new URL(input.endpoint); + for (const [key, value] of Object.entries(input.extraParameters ?? {})) { + if (PROTECTED_PARAMETERS.has(key.toLowerCase())) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + `The Provider parameter "${key}" is owned by the OAuth flow.` + ); + } + url.searchParams.set(key, value); + } + + url.searchParams.set('client_id', input.clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', input.scopes.join(' ')); + url.searchParams.set('state', input.state); + url.searchParams.set('code_challenge', input.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + if (input.nonce) url.searchParams.set('nonce', input.nonce); + + return url.toString(); +}; diff --git a/packages/oauth/src/endpoint.ts b/packages/oauth/src/endpoint.ts new file mode 100644 index 0000000000..44915ca026 --- /dev/null +++ b/packages/oauth/src/endpoint.ts @@ -0,0 +1,102 @@ +import { isIP } from 'net'; + +import { ProviderAdapterError, type ValidatedEndpoint } from './types'; + +const isUnsafeIpv4 = (hostname: string): boolean => { + const octets = hostname.split('.').map(Number); + if (octets.length !== 4 || octets.some(value => !Number.isInteger(value))) { + return true; + } + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 + ); +}; + +const isUnsafeIpv6 = (hostname: string): boolean => { + const value = hostname.toLowerCase(); + return ( + value === '::' || + value === '::1' || + value.startsWith('fc') || + value.startsWith('fd') || + /^fe[89ab]/.test(value) || + value.startsWith('ff') + ); +}; + +const isUnsafeHostname = (hostname: string): boolean => { + const normalized = hostname + .toLowerCase() + .replace(/^\[/, '') + .replace(/\]$/, '') + .replace(/\.$/, ''); + if (normalized === 'localhost' || normalized.endsWith('.localhost')) { + return true; + } + const family = isIP(normalized); + return family === 4 + ? isUnsafeIpv4(normalized) + : family === 6 + ? isUnsafeIpv6(normalized) + : false; +}; + +const canonicalEndpoint = (value: string): string => { + let url: URL; + try { + url = new URL(value); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider endpoint is not a valid URL.', + { cause } + ); + } + + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + isUnsafeHostname(url.hostname) + ) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider endpoint is not an approved HTTPS URL.' + ); + } + return url.toString(); +}; + +/** Validate one configured endpoint against a concrete adapter's exact list. */ +export const validateProviderEndpoint = ( + value: string | null | undefined, + allowed: readonly string[] +): ValidatedEndpoint => { + if (!value) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'A required Provider endpoint is not configured.' + ); + } + const endpoint = canonicalEndpoint(value); + const allowlist = allowed.map(canonicalEndpoint); + if (!allowlist.includes(endpoint)) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The configured Provider endpoint is not supported.' + ); + } + return endpoint as ValidatedEndpoint; +}; diff --git a/packages/oauth/src/http.ts b/packages/oauth/src/http.ts new file mode 100644 index 0000000000..cccec0f108 --- /dev/null +++ b/packages/oauth/src/http.ts @@ -0,0 +1,118 @@ +import { + ProviderAdapterError, + type ValidatedEndpoint +} from './types'; + +const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024; + +export interface ProviderJsonRequestOptions { + timeoutMs: number; + fetch?: typeof fetch; + maxResponseBytes?: number; +} + +const readBoundedBody = async ( + response: Response, + maxBytes: number +): Promise => { + const contentLength = response.headers.get('content-length'); + if (contentLength && Number(contentLength) > maxBytes) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response exceeded the allowed size.' + ); + } + + if (!response.body) return ''; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel(); + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response exceeded the allowed size.' + ); + } + chunks.push(value); + } + return Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8'); +}; + +/** Bounded, no-redirect JSON request for already allowlisted endpoints. */ +export const requestProviderJson = async ( + endpoint: ValidatedEndpoint, + init: Omit, + options: ProviderJsonRequestOptions +): Promise => { + if (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider request timeout is invalid.' + ); + } + + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, options.timeoutMs); + + try { + const response = await (options.fetch ?? fetch)(endpoint, { + ...init, + redirect: 'error', + signal: controller.signal + }); + + if (!response.ok) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned an unsuccessful response.', + { status: response.status } + ); + } + + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if ( + !contentType.includes('application/json') && + !contentType.includes('+json') + ) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned an unsupported response type.' + ); + } + + const body = await readBoundedBody( + response, + options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES + ); + try { + return JSON.parse(body); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned invalid JSON.', + { cause } + ); + } + } catch (cause) { + if (cause instanceof ProviderAdapterError) throw cause; + throw new ProviderAdapterError( + timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_FAILURE', + timedOut + ? 'The Provider request timed out.' + : 'The Provider request failed.', + { cause } + ); + } finally { + clearTimeout(timeout); + } +}; diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 42d1c94d82..0d27d33c3c 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -1,30 +1,39 @@ +export type { ProviderAdapter } from './adapter'; export { - createOAuthMiddleware, - generateState, - OAuthCallbackContext, - OAuthErrorContext, - OAuthMiddlewareConfig, - OAuthRouteHandlers, - verifyState, -} from './middleware/express'; -export { createOAuthClient,OAuthClient } from './oauth-client'; + type AuthorizationUrlInput, + createAuthorizationUrl, + validateProviderCallbackUri} from './authorization'; +export { validateProviderEndpoint } from './endpoint'; export { - facebookProvider, - getProvider, - getProviderIds, - githubProvider, - googleProvider, - linkedinProvider, - providers, + type ProviderJsonRequestOptions, + requestProviderJson} from './http'; +export { + constantTimeEqual, + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + isOpaqueOAuthValue +} from './primitives'; +export type { + ValidatedGitHubConfiguration, + ValidatedGoogleConfiguration +} from './providers'; +export { + getProviderAdapter, + getProviderAdapterKinds, + githubAdapter, + googleAdapter } from './providers'; export { - AuthorizationUrlParams, - CallbackParams, - createOAuthError, - OAuthClientConfig, - OAuthCredentials, - OAuthError, - OAuthProfile, - OAuthProviderConfig, - TokenResponse, + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ProviderAuthorizationInput, + type ProviderAuthorizationResult, + type ProviderCallbackInput, + type ProviderFailureReason, + type SafeExternalProfile, + type ValidatedEndpoint, + type ValidatedProviderConfiguration } from './types'; diff --git a/packages/oauth/src/middleware/express.ts b/packages/oauth/src/middleware/express.ts deleted file mode 100644 index 607d2d76a4..0000000000 --- a/packages/oauth/src/middleware/express.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { OAuthClient } from '../oauth-client'; -import { getProviderIds } from '../providers'; -import { createOAuthError,OAuthClientConfig, OAuthProfile } from '../types'; -import { generateState, verifyState } from '../utils/state'; - -export interface OAuthMiddlewareConfig extends OAuthClientConfig { - onSuccess: (profile: OAuthProfile, context: OAuthCallbackContext) => Promise; - onError?: (error: Error, context: OAuthErrorContext) => void; - successRedirect?: string; - errorRedirect?: string; -} - -export interface OAuthCallbackContext { - provider: string; - profile: OAuthProfile; - query: Record; -} - -export interface OAuthErrorContext { - provider?: string; - error: Error; - query: Record; -} - -export interface OAuthRouteHandlers { - initiateAuth: ( - req: { params: { provider: string }; query: Record }, - res: { - redirect: (url: string) => void; - cookie: (name: string, value: string, options: Record) => void; - status: (code: number) => { json: (data: unknown) => void }; - } - ) => void; - - handleCallback: ( - req: { - params: { provider: string }; - query: Record; - cookies: Record; - }, - res: { - redirect: (url: string) => void; - clearCookie: (name: string) => void; - status: (code: number) => { json: (data: unknown) => void }; - json: (data: unknown) => void; - } - ) => Promise; - - getProviders: ( - req: unknown, - res: { json: (data: unknown) => void } - ) => void; -} - -export function createOAuthMiddleware(config: OAuthMiddlewareConfig): OAuthRouteHandlers { - const client = new OAuthClient(config); - const clientConfig = client.getConfig(); - - const initiateAuth: OAuthRouteHandlers['initiateAuth'] = (req, res) => { - const { provider } = req.params; - - try { - const { url, state } = client.getAuthorizationUrl({ provider }); - - res.cookie(clientConfig.stateCookieName!, state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - maxAge: (clientConfig.stateCookieMaxAge || 600) * 1000, - sameSite: 'lax', - }); - - res.redirect(url); - } catch (error) { - if (config.onError) { - config.onError(error as Error, { - provider, - error: error as Error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', (error as Error).message); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'oauth_error', - message: (error as Error).message, - provider, - }); - } - } - }; - - const handleCallback: OAuthRouteHandlers['handleCallback'] = async (req, res) => { - const { provider } = req.params; - const { code, state, error: oauthError, error_description } = req.query as Record< - string, - string - >; - - const storedState = req.cookies[clientConfig.stateCookieName!]; - res.clearCookie(clientConfig.stateCookieName!); - - if (oauthError) { - const error = createOAuthError( - error_description || oauthError, - 'OAUTH_PROVIDER_ERROR', - provider - ); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', oauthError); - if (error_description) { - errorUrl.searchParams.set('error_description', error_description); - } - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'oauth_error', - message: error_description || oauthError, - provider, - }); - } - return; - } - - if (!verifyState(storedState, state)) { - const error = createOAuthError('Invalid state parameter', 'INVALID_STATE', provider); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'invalid_state'); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'invalid_state', - message: 'Invalid state parameter', - provider, - }); - } - return; - } - - if (!code) { - const error = createOAuthError('Missing authorization code', 'MISSING_CODE', provider); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'missing_code'); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'missing_code', - message: 'Missing authorization code', - provider, - }); - } - return; - } - - try { - const profile = await client.handleCallback({ provider, code }); - - const result = await config.onSuccess(profile, { - provider, - profile, - query: req.query as Record, - }); - - if (config.successRedirect) { - res.redirect(config.successRedirect); - } else { - res.json({ success: true, data: result }); - } - } catch (error) { - if (config.onError) { - config.onError(error as Error, { - provider, - error: error as Error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'callback_failed'); - errorUrl.searchParams.set('message', (error as Error).message); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(500).json({ - error: 'callback_failed', - message: (error as Error).message, - provider, - }); - } - } - }; - - const getProviders: OAuthRouteHandlers['getProviders'] = (_req, res) => { - const configuredProviders = Object.keys(config.providers); - const availableProviders = getProviderIds().filter((id) => configuredProviders.includes(id)); - res.json({ providers: availableProviders }); - }; - - return { - initiateAuth, - handleCallback, - getProviders, - }; -} - -export { generateState, verifyState }; diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts deleted file mode 100644 index cbdd348e50..0000000000 --- a/packages/oauth/src/oauth-client.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { extractPrimaryEmail,getProvider, GITHUB_EMAILS_URL } from './providers'; -import { - AuthorizationUrlParams, - CallbackParams, - createOAuthError, - OAuthClientConfig, - OAuthProfile, - TokenResponse, -} from './types'; -import { generateState } from './utils/state'; - -export class OAuthClient { - private config: OAuthClientConfig; - - constructor(config: OAuthClientConfig) { - this.config = { - callbackPath: '/auth/{provider}/callback', - stateCookieName: 'oauth_state', - stateCookieMaxAge: 600, - ...config, - }; - } - - getAuthorizationUrl(params: AuthorizationUrlParams): { url: string; state: string } { - const { provider: providerId, state: customState, redirectUri, scopes } = params; - - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const credentials = this.config.providers[providerId]; - if (!credentials) { - throw createOAuthError( - `No credentials configured for provider: ${providerId}`, - 'MISSING_CREDENTIALS', - providerId - ); - } - - const state = customState || generateState(); - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); - const effectiveScopes = scopes || provider.scopes; - - const url = new URL(provider.authorizationUrl); - url.searchParams.set('client_id', credentials.clientId); - url.searchParams.set('redirect_uri', callbackUrl); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', effectiveScopes.join(' ')); - url.searchParams.set('state', state); - - return { url: url.toString(), state }; - } - - async exchangeCode(params: CallbackParams): Promise { - const { provider: providerId, code, redirectUri } = params; - - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const credentials = this.config.providers[providerId]; - if (!credentials) { - throw createOAuthError( - `No credentials configured for provider: ${providerId}`, - 'MISSING_CREDENTIALS', - providerId - ); - } - - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); - - const body: Record = { - client_id: credentials.clientId, - client_secret: credentials.clientSecret, - code, - redirect_uri: callbackUrl, - grant_type: 'authorization_code', - }; - - const headers: Record = { - Accept: 'application/json', - }; - - let requestBody: string; - if (provider.tokenRequestContentType === 'json') { - headers['Content-Type'] = 'application/json'; - requestBody = JSON.stringify(body); - } else { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - requestBody = new URLSearchParams(body).toString(); - } - - const response = await fetch(provider.tokenUrl, { - method: 'POST', - headers, - body: requestBody, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw createOAuthError( - `Token exchange failed: ${errorText}`, - 'TOKEN_EXCHANGE_FAILED', - providerId, - response.status - ); - } - - const data = await response.json(); - - if (data.error) { - throw createOAuthError( - `Token exchange error: ${data.error_description || data.error}`, - 'TOKEN_EXCHANGE_ERROR', - providerId - ); - } - - return data as TokenResponse; - } - - async getUserProfile(providerId: string, accessToken: string): Promise { - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }; - - if (providerId === 'github') { - headers['User-Agent'] = 'Constructive-OAuth'; - } - - const response = await fetch(provider.userInfoUrl, { - method: provider.userInfoMethod || 'GET', - headers, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw createOAuthError( - `Failed to fetch user profile: ${errorText}`, - 'USER_PROFILE_FAILED', - providerId, - response.status - ); - } - - const data = await response.json(); - let profile = provider.mapProfile(data); - - if (providerId === 'github' && !profile.email) { - profile = await this.fetchGitHubEmail(accessToken, profile); - } - - return profile; - } - - async handleCallback(params: CallbackParams): Promise { - const tokens = await this.exchangeCode(params); - return this.getUserProfile(params.provider, tokens.access_token); - } - - private async fetchGitHubEmail( - accessToken: string, - profile: OAuthProfile - ): Promise { - try { - const response = await fetch(GITHUB_EMAILS_URL, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'User-Agent': 'Constructive-OAuth', - }, - }); - - if (response.ok) { - const emails = await response.json(); - const email = extractPrimaryEmail(emails); - if (email) { - return { ...profile, email }; - } - } - } catch { - // Ignore email fetch errors, return profile without email - } - return profile; - } - - private getCallbackUrl(providerId: string, customRedirectUri?: string): string { - if (customRedirectUri) { - return customRedirectUri; - } - const path = this.config.callbackPath!.replace('{provider}', providerId); - return `${this.config.baseUrl}${path}`; - } - - getConfig(): OAuthClientConfig { - return this.config; - } -} - -export function createOAuthClient(config: OAuthClientConfig): OAuthClient { - return new OAuthClient(config); -} diff --git a/packages/oauth/src/primitives.ts b/packages/oauth/src/primitives.ts new file mode 100644 index 0000000000..c510da7e00 --- /dev/null +++ b/packages/oauth/src/primitives.ts @@ -0,0 +1,35 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto'; + +import { ProviderAdapterError } from './types'; + +const BASE64URL_VALUE = /^[A-Za-z0-9_-]+$/; +const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/; + +export const generateOpaqueState = (): string => randomBytes(32).toString('base64url'); + +export const generateCodeVerifier = (): string => randomBytes(32).toString('base64url'); + +export const generateOidcNonce = (): string => randomBytes(32).toString('base64url'); + +export const deriveS256CodeChallenge = (verifier: string): string => { + if (!PKCE_VERIFIER.test(verifier)) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The PKCE verifier does not satisfy RFC 7636.' + ); + } + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +}; + +export const isOpaqueOAuthValue = (value: string, byteLength = 32): boolean => + value.length === Math.ceil((byteLength * 4) / 3) && + BASE64URL_VALUE.test(value); + +export const constantTimeEqual = (expected: string, actual: string): boolean => { + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + return ( + expectedBuffer.length === actualBuffer.length && + timingSafeEqual(expectedBuffer, actualBuffer) + ); +}; diff --git a/packages/oauth/src/providers/common.ts b/packages/oauth/src/providers/common.ts new file mode 100644 index 0000000000..387483e8f8 --- /dev/null +++ b/packages/oauth/src/providers/common.ts @@ -0,0 +1,105 @@ +import { + type IdentityProviderConfiguration, + ProviderAdapterError, + type SafeExternalProfile, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; + +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const optionalString = ( + input: Record, + key: string +): string | undefined => { + const value = input[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +}; + +export const requiredString = ( + input: Record, + key: string +): string => { + const value = optionalString(input, key); + if (!value) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response is missing a required value.' + ); + } + return value; +}; + +export const configurationValue = ( + config: IdentityProviderConfiguration, + direct: string | null, + discoveryKey: string +): string | null => { + if (direct) return direct; + const discovered = config.discoveryDoc?.[discoveryKey]; + return typeof discovered === 'string' ? discovered : null; +}; + +export const validateCommonConfiguration = ( + input: IdentityProviderConfiguration, + adapterKind: string, + authorizationEndpoint: ValidatedEndpoint, + tokenEndpoint: ValidatedEndpoint +): ValidatedProviderConfiguration => { + if (!input.enabled || !input.clientId || !input.clientSecret) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected Provider is not enabled or is missing credentials.' + ); + } + if (!input.pkceEnabled) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'S256 PKCE is required for every Provider.' + ); + } + if (!input.scopes.length) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected Provider has no configured scopes.' + ); + } + return { + adapterKind, + providerKey: input.slug, + displayName: input.displayName, + clientId: input.clientId, + clientSecret: input.clientSecret, + authorizationEndpoint, + tokenEndpoint, + scopes: [...input.scopes], + extraAuthorizationParams: { ...input.extraAuthorizationParams } + }; +}; + +export const safeProfileValue = ( + value: unknown, + maxLength = 512 +): string | undefined => + typeof value === 'string' && value.length > 0 && value.length <= maxLength + ? value + : undefined; + +export const safeAvatarUrl = (value: unknown): string | undefined => { + const candidate = safeProfileValue(value, 2048); + if (!candidate) return undefined; + try { + const url = new URL(candidate); + return url.protocol === 'https:' && !url.username && !url.password + ? url.toString() + : undefined; + } catch { + return undefined; + } +}; + +export const compactProfile = (profile: SafeExternalProfile): SafeExternalProfile => + Object.fromEntries( + Object.entries(profile).filter(([, value]) => value !== undefined) + ) as SafeExternalProfile; diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts deleted file mode 100644 index 41ed451bff..0000000000 --- a/packages/oauth/src/providers/facebook.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface FacebookProfile { - id: string; - name?: string; - email?: string; - picture?: { - data?: { - url?: string; - }; - }; -} - -const FACEBOOK_API_VERSION = 'v18.0'; - -export const facebookProvider: OAuthProviderConfig = { - id: 'facebook', - name: 'Facebook', - authorizationUrl: `https://www.facebook.com/${FACEBOOK_API_VERSION}/dialog/oauth`, - tokenUrl: `https://graph.facebook.com/${FACEBOOK_API_VERSION}/oauth/access_token`, - userInfoUrl: `https://graph.facebook.com/me?fields=id,name,email,picture`, - scopes: ['email', 'public_profile'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as FacebookProfile; - return { - provider: 'facebook', - providerId: profile.id, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture?.data?.url || null, - raw: data, - }; - }, -}; diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts index 72ec51c4aa..f59047ce0c 100644 --- a/packages/oauth/src/providers/github.ts +++ b/packages/oauth/src/providers/github.ts @@ -1,46 +1,207 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface GitHubProfile { - id: number; - login: string; - name?: string; - email?: string; - avatar_url?: string; -} +import type { ProviderAdapter } from '../adapter'; +import { + createAuthorizationUrl, + validateProviderCallbackUri +} from '../authorization'; +import { validateProviderEndpoint } from '../endpoint'; +import { requestProviderJson } from '../http'; +import { deriveS256CodeChallenge } from '../primitives'; +import { + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; +import { + compactProfile, + configurationValue, + isRecord, + requiredString, + safeAvatarUrl, + safeProfileValue, + validateCommonConfiguration +} from './common'; + +const GITHUB_AUTHORIZATION_ENDPOINTS = [ + 'https://github.com/login/oauth/authorize' +] as const; +const GITHUB_TOKEN_ENDPOINTS = [ + 'https://github.com/login/oauth/access_token' +] as const; +const GITHUB_USER_ENDPOINTS = ['https://api.github.com/user'] as const; +const GITHUB_EMAIL_ENDPOINTS = ['https://api.github.com/user/emails'] as const; -interface GitHubEmail { - email: string; - primary: boolean; - verified: boolean; +export interface ValidatedGitHubConfiguration + extends ValidatedProviderConfiguration { + userEndpoint: ValidatedEndpoint; + emailEndpoint: ValidatedEndpoint; } -export const githubProvider: OAuthProviderConfig = { - id: 'github', - name: 'GitHub', - authorizationUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - userInfoUrl: 'https://api.github.com/user', - scopes: ['user:email', 'read:user'], - tokenRequestContentType: 'json', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as GitHubProfile; - return { - provider: 'github', - providerId: String(profile.id), - email: profile.email || null, - name: profile.name || profile.login || null, - picture: profile.avatar_url || null, - raw: data, - }; - }, +const validateGitHubConfiguration = ( + input: IdentityProviderConfiguration +): ValidatedGitHubConfiguration => { + const authorizationEndpoint = validateProviderEndpoint( + configurationValue(input, input.authorizationUrl, 'authorization_endpoint'), + GITHUB_AUTHORIZATION_ENDPOINTS + ); + const tokenEndpoint = validateProviderEndpoint( + configurationValue(input, input.tokenUrl, 'token_endpoint'), + GITHUB_TOKEN_ENDPOINTS + ); + const userEndpoint = validateProviderEndpoint( + configurationValue(input, input.userinfoUrl, 'userinfo_endpoint'), + GITHUB_USER_ENDPOINTS + ); + const configuredEmailEndpoint = configurationValue( + input, + null, + 'emails_endpoint' + ); + const emailEndpoint = validateProviderEndpoint( + configuredEmailEndpoint ?? `${userEndpoint}/emails`, + GITHUB_EMAIL_ENDPOINTS + ); + + return { + ...validateCommonConfiguration( + input, + 'github', + authorizationEndpoint, + tokenEndpoint + ), + userEndpoint, + emailEndpoint + }; }; -export const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails'; +const githubHeaders = (accessToken?: string): Record => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': 'Constructive-OAuth', + ...(accessToken && { Authorization: `Bearer ${accessToken}` }) +}); -export function extractPrimaryEmail(emails: GitHubEmail[]): string | null { - const primary = emails.find((e) => e.primary && e.verified); - if (primary) return primary.email; - const verified = emails.find((e) => e.verified); - if (verified) return verified.email; - return emails[0]?.email || null; -} +const findEmail = ( + response: unknown +): { email?: string; verified?: boolean } => { + if (!Array.isArray(response)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid email response.' + ); + } + const emails = response.filter(isRecord); + const selected = + emails.find(value => value.primary === true && value.verified === true) ?? + emails.find(value => value.verified === true) ?? + emails.find(value => typeof value.email === 'string'); + return selected + ? { + email: safeProfileValue(selected.email), + verified: + typeof selected.verified === 'boolean' ? selected.verified : undefined + } + : {}; +}; + +export const githubAdapter: ProviderAdapter = { + kind: 'github', + + validateConfiguration: validateGitHubConfiguration, + + createAuthorizationRequest: input => ({ + url: createAuthorizationUrl({ + endpoint: input.config.authorizationEndpoint, + clientId: input.config.clientId, + redirectUri: input.redirectUri, + scopes: input.config.scopes, + state: input.state, + codeChallenge: input.codeChallenge, + extraParameters: input.config.extraAuthorizationParams + }) + }), + + completeAuthorization: async input => { + deriveS256CodeChallenge(input.codeVerifier); + validateProviderCallbackUri(input.redirectUri); + if (!input.code) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'GitHub callback verification requires an authorization code.' + ); + } + const tokenResponse = await requestProviderJson( + input.config.tokenEndpoint, + { + method: 'POST', + headers: { + ...githubHeaders(), + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: input.config.clientId, + client_secret: input.config.clientSecret, + code: input.code, + code_verifier: input.codeVerifier, + redirect_uri: input.redirectUri + }).toString() + }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(tokenResponse)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid token response.' + ); + } + const accessToken = requiredString(tokenResponse, 'access_token'); + + const user = await requestProviderJson( + input.config.userEndpoint, + { headers: githubHeaders(accessToken) }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(user)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid profile response.' + ); + } + const id = user.id; + if ( + (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) && + (typeof id !== 'string' || !id) + ) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid stable identifier.' + ); + } + + let email = safeProfileValue(user.email); + let emailVerified: boolean | undefined; + if (!email) { + const emailResult = findEmail( + await requestProviderJson( + input.config.emailEndpoint, + { headers: githubHeaders(accessToken) }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ) + ); + email = emailResult.email; + emailVerified = emailResult.verified; + } + + return { + providerKey: input.config.providerKey, + subject: String(id), + email, + profile: compactProfile({ + name: safeProfileValue(user.name), + username: safeProfileValue(user.login), + avatarUrl: safeAvatarUrl(user.avatar_url), + emailVerified + }) + } satisfies NormalizedExternalIdentity; + } +}; diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts index dd1c399bb8..8e87f5db0a 100644 --- a/packages/oauth/src/providers/google.ts +++ b/packages/oauth/src/providers/google.ts @@ -1,32 +1,235 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface GoogleProfile { - sub: string; - email?: string; - email_verified?: boolean; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; +import { + createLocalJWKSet, + type JSONWebKeySet, + jwtVerify +} from 'jose'; + +import type { ProviderAdapter } from '../adapter'; +import { + createAuthorizationUrl, + validateProviderCallbackUri +} from '../authorization'; +import { validateProviderEndpoint } from '../endpoint'; +import { requestProviderJson } from '../http'; +import { deriveS256CodeChallenge } from '../primitives'; +import { + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; +import { + compactProfile, + configurationValue, + isRecord, + optionalString, + safeAvatarUrl, + safeProfileValue, + validateCommonConfiguration +} from './common'; + +const GOOGLE_AUTHORIZATION_ENDPOINTS = [ + 'https://accounts.google.com/o/oauth2/v2/auth' +] as const; +const GOOGLE_TOKEN_ENDPOINTS = ['https://oauth2.googleapis.com/token'] as const; +const GOOGLE_ISSUERS = ['https://accounts.google.com'] as const; +const GOOGLE_JWKS_ENDPOINTS = [ + 'https://www.googleapis.com/oauth2/v3/certs', + 'https://www.googleapis.com/oauth2/v1/certs' +] as const; + +export interface ValidatedGoogleConfiguration + extends ValidatedProviderConfiguration { + issuer: string; + acceptableAudiences: readonly string[]; + jwks?: JSONWebKeySet; + jwksEndpoint?: ValidatedEndpoint; } -export const googleProvider: OAuthProviderConfig = { - id: 'google', - name: 'Google', - authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenUrl: 'https://oauth2.googleapis.com/token', - userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', - scopes: ['openid', 'email', 'profile'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as GoogleProfile; +const validateJwks = (value: Record | null): JSONWebKeySet | undefined => { + if (!value) return undefined; + if (!Array.isArray(value.keys)) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider JWKS configuration is invalid.' + ); + } + return value as unknown as JSONWebKeySet; +}; + +const validateGoogleConfiguration = ( + input: IdentityProviderConfiguration +): ValidatedGoogleConfiguration => { + if (input.skipNonceCheck) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'OIDC nonce verification is required for Google.' + ); + } + if (!input.scopes.includes('openid')) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider must include the openid scope.' + ); + } + + const authorizationEndpoint = validateProviderEndpoint( + configurationValue(input, input.authorizationUrl, 'authorization_endpoint'), + GOOGLE_AUTHORIZATION_ENDPOINTS + ); + const tokenEndpoint = validateProviderEndpoint( + configurationValue(input, input.tokenUrl, 'token_endpoint'), + GOOGLE_TOKEN_ENDPOINTS + ); + const issuerEndpoint = validateProviderEndpoint( + configurationValue(input, input.issuerUrl, 'issuer'), + GOOGLE_ISSUERS + ); + const jwks = validateJwks(input.jwks); + const jwksValue = configurationValue(input, null, 'jwks_uri'); + const jwksEndpoint = jwksValue + ? validateProviderEndpoint(jwksValue, GOOGLE_JWKS_ENDPOINTS) + : undefined; + if (!jwks && !jwksEndpoint) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider has no configured JWKS source.' + ); + } + + return { + ...validateCommonConfiguration( + input, + 'google', + authorizationEndpoint, + tokenEndpoint + ), + issuer: issuerEndpoint.replace(/\/$/, ''), + acceptableAudiences: [input.clientId, ...input.acceptableClientIds], + jwks, + jwksEndpoint + }; +}; + +export const googleAdapter: ProviderAdapter = { + kind: 'google', + + validateConfiguration: validateGoogleConfiguration, + + createAuthorizationRequest: input => { + if (!input.nonce) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'Google authorization requires an OIDC nonce.' + ); + } return { - provider: 'google', - providerId: profile.sub, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture || null, - raw: data, + url: createAuthorizationUrl({ + endpoint: input.config.authorizationEndpoint, + clientId: input.config.clientId, + redirectUri: input.redirectUri, + scopes: input.config.scopes, + state: input.state, + codeChallenge: input.codeChallenge, + nonce: input.nonce, + extraParameters: input.config.extraAuthorizationParams + }) }; }, + + completeAuthorization: async input => { + deriveS256CodeChallenge(input.codeVerifier); + validateProviderCallbackUri(input.redirectUri); + if (!input.code || !input.nonce) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'Google callback verification requires a code and the original nonce.' + ); + } + + const tokenResponse = await requestProviderJson( + input.config.tokenEndpoint, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: input.config.clientId, + client_secret: input.config.clientSecret, + code: input.code, + code_verifier: input.codeVerifier, + grant_type: 'authorization_code', + redirect_uri: input.redirectUri + }).toString() + }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(tokenResponse)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google returned an invalid token response.' + ); + } + const identityToken = optionalString(tokenResponse, 'id_token'); + if (!identityToken) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google did not return an identity token.' + ); + } + + let jwks = input.config.jwks; + if (!jwks && input.config.jwksEndpoint) { + const remote = await requestProviderJson( + input.config.jwksEndpoint, + { headers: { Accept: 'application/json' } }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(remote) || !Array.isArray(remote.keys)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google returned an invalid JWKS response.' + ); + } + jwks = remote as unknown as JSONWebKeySet; + } + + try { + const { payload } = await jwtVerify(identityToken, createLocalJWKSet(jwks!), { + algorithms: ['RS256'], + audience: [...input.config.acceptableAudiences], + issuer: input.config.issuer + }); + if (payload.nonce !== input.nonce || typeof payload.sub !== 'string') { + throw new ProviderAdapterError( + 'IDENTITY_VERIFICATION_FAILED', + 'Google identity verification failed.' + ); + } + + return { + providerKey: input.config.providerKey, + subject: payload.sub, + email: safeProfileValue(payload.email), + profile: compactProfile({ + name: safeProfileValue(payload.name), + avatarUrl: safeAvatarUrl(payload.picture), + emailVerified: + typeof payload.email_verified === 'boolean' + ? payload.email_verified + : undefined + }) + } satisfies NormalizedExternalIdentity; + } catch (cause) { + if (cause instanceof ProviderAdapterError) throw cause; + throw new ProviderAdapterError( + 'IDENTITY_VERIFICATION_FAILED', + 'Google identity verification failed.', + { cause } + ); + } + } }; diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index 23927d410c..2415066ff9 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -1,29 +1,28 @@ -import { OAuthProviderConfig } from '../types'; -import { facebookProvider } from './facebook'; -import { extractPrimaryEmail,GITHUB_EMAILS_URL, githubProvider } from './github'; -import { googleProvider } from './google'; -import { linkedinProvider } from './linkedin'; +import type { ProviderAdapter } from '../adapter'; +import { ProviderAdapterError } from '../types'; +import { githubAdapter } from './github'; +import { googleAdapter } from './google'; -export const providers: Record = { - google: googleProvider, - github: githubProvider, - facebook: facebookProvider, - linkedin: linkedinProvider, -}; +const providerAdapters = new Map([ + [googleAdapter.kind, googleAdapter as ProviderAdapter], + [githubAdapter.kind, githubAdapter as ProviderAdapter] +]); -export function getProvider(id: string): OAuthProviderConfig | undefined { - return providers[id]; -} +export const getProviderAdapter = (providerKey: string): ProviderAdapter => { + const adapter = providerAdapters.get(providerKey); + if (!adapter) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected identity Provider is not supported.' + ); + } + return adapter; +}; -export function getProviderIds(): string[] { - return Object.keys(providers); -} +export const getProviderAdapterKinds = (): readonly string[] => + [...providerAdapters.keys()]; -export { - extractPrimaryEmail, - facebookProvider, - GITHUB_EMAILS_URL, - githubProvider, - googleProvider, - linkedinProvider, -}; +export type { ValidatedGitHubConfiguration } from './github'; +export { githubAdapter } from './github'; +export type { ValidatedGoogleConfiguration } from './google'; +export { googleAdapter } from './google'; diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts deleted file mode 100644 index 9050c9be6a..0000000000 --- a/packages/oauth/src/providers/linkedin.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface LinkedInProfile { - sub: string; - email?: string; - email_verified?: boolean; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; -} - -export const linkedinProvider: OAuthProviderConfig = { - id: 'linkedin', - name: 'LinkedIn', - authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', - tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', - userInfoUrl: 'https://api.linkedin.com/v2/userinfo', - scopes: ['openid', 'profile', 'email'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as LinkedInProfile; - return { - provider: 'linkedin', - providerId: profile.sub, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture || null, - raw: data, - }; - }, -}; diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index db4ef07437..2c99238a54 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -1,74 +1,108 @@ -export interface OAuthProviderConfig { - id: string; - name: string; - authorizationUrl: string; - tokenUrl: string; - userInfoUrl: string; +export interface IdentityProviderConfiguration { + slug: string; + kind: string; + displayName: string; + enabled: boolean; + clientId: string; + clientSecret: string | null; + authorizationUrl: string | null; + tokenUrl: string | null; + userinfoUrl: string | null; + issuerUrl: string | null; + discoveryDoc: Record | null; + jwks: Record | null; + acceptableClientIds: string[]; scopes: string[]; - tokenRequestContentType?: 'json' | 'form'; - userInfoMethod?: 'GET' | 'POST'; - mapProfile: (data: unknown) => OAuthProfile; + extraAuthorizationParams: Record; + emailOptional: boolean; + skipNonceCheck: boolean; + pkceEnabled: boolean; } -export interface OAuthProfile { - provider: string; - providerId: string; - email: string | null; - name: string | null; - picture: string | null; - raw: unknown; -} +declare const validatedEndpoint: unique symbol; -export interface OAuthCredentials { +/** An HTTPS endpoint that has passed a concrete adapter's exact allowlist. */ +export type ValidatedEndpoint = string & { + readonly [validatedEndpoint]: true; +}; + +export interface ValidatedProviderConfiguration { + adapterKind: string; + providerKey: string; + displayName: string; clientId: string; clientSecret: string; - redirectUri?: string; + authorizationEndpoint: ValidatedEndpoint; + tokenEndpoint: ValidatedEndpoint; + scopes: readonly string[]; + extraAuthorizationParams: Readonly>; } -export interface OAuthClientConfig { - providers: Record; - baseUrl: string; - callbackPath?: string; - stateCookieName?: string; - stateCookieMaxAge?: number; +export interface ProviderAuthorizationInput< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + config: C; + redirectUri: string; + state: string; + codeChallenge: string; + nonce?: string; } -export interface TokenResponse { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - scope?: string; +export interface ProviderAuthorizationResult { + url: string; } -export interface AuthorizationUrlParams { - provider: string; - state?: string; - redirectUri?: string; - scopes?: string[]; +export interface ProviderCallbackInput< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + config: C; + redirectUri: string; + code: string; + codeVerifier: string; + nonce?: string; + requestTimeoutMs: number; + fetch?: typeof fetch; } -export interface CallbackParams { - provider: string; - code: string; - redirectUri?: string; +export interface SafeExternalProfile { + name?: string; + username?: string; + avatarUrl?: string; + emailVerified?: boolean; } -export interface OAuthError extends Error { - code: string; - provider?: string; - statusCode?: number; +/** The only Provider result consumed by common Constructive orchestration. */ +export interface NormalizedExternalIdentity { + providerKey: string; + subject: string; + email?: string; + profile: SafeExternalProfile; } -export function createOAuthError( - message: string, - code: string, - provider?: string, - statusCode?: number -): OAuthError { - const error = new Error(message) as OAuthError; - error.code = code; - error.provider = provider; - error.statusCode = statusCode; - return error; +export type ProviderFailureReason = + | 'INVALID_CONFIGURATION' + | 'INVALID_AUTHORIZATION_INPUT' + | 'NETWORK_FAILURE' + | 'REQUEST_TIMEOUT' + | 'INVALID_RESPONSE' + | 'IDENTITY_VERIFICATION_FAILED'; + +/** + * Package-local failure classification. Transport owners map it to canonical + * Constructive errors and never expose Provider response bodies. + */ +export class ProviderAdapterError extends Error { + readonly reason: ProviderFailureReason; + readonly status?: number; + + constructor( + reason: ProviderFailureReason, + message: string, + options?: ErrorOptions & { status?: number } + ) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'ProviderAdapterError'; + this.reason = reason; + this.status = options?.status; + } } diff --git a/packages/oauth/src/utils/state.ts b/packages/oauth/src/utils/state.ts deleted file mode 100644 index c7a60f2818..0000000000 --- a/packages/oauth/src/utils/state.ts +++ /dev/null @@ -1 +0,0 @@ -export { generateToken as generateState, verifyToken as verifyState } from '@constructive-io/csrf'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257ab13c58..b3a8802ef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2586,9 +2586,9 @@ importers: packages/oauth: dependencies: - '@constructive-io/csrf': - specifier: workspace:^ - version: link:../csrf/dist + jose: + specifier: ^5.10.0 + version: 5.10.0 devDependencies: '@types/node': specifier: ^22.19.11 @@ -11645,6 +11645,12 @@ packages: } hasBin: true + jose@5.10.0: + resolution: + { + integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==, + } + js-tokens@4.0.0: resolution: { @@ -21173,6 +21179,8 @@ snapshots: jiti@2.7.0: {} + jose@5.10.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: From 331c82dc827578684146821b9c0ba8e16467e25f Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 21:14:35 +0800 Subject: [PATCH 2/2] feat: add tenant SSO context surface --- .../src/middleware/__tests__/routing.test.ts | 2 + graphql/server/src/middleware/graphile.ts | 5 ++ graphql/server/src/middleware/routing.ts | 3 + packages/express-context/README.md | 18 +++++ .../__tests__/loaders/auth-loaders.test.ts | 32 ++++++-- .../__tests__/loaders/sso-surface.test.ts | 74 +++++++++++++++++++ .../__tests__/pg-settings.test.ts | 16 +++- packages/express-context/src/context.ts | 3 +- packages/express-context/src/index.ts | 2 + .../src/loaders/identity-providers.ts | 47 +++++++----- packages/express-context/src/loaders/index.ts | 2 + .../src/loaders/sso-surface.ts | 45 +++++++++++ packages/express-context/src/pg-settings.ts | 7 ++ packages/express-context/src/types.ts | 10 +++ 14 files changed, 242 insertions(+), 24 deletions(-) create mode 100644 packages/express-context/__tests__/loaders/sso-surface.test.ts create mode 100644 packages/express-context/src/loaders/sso-surface.ts diff --git a/graphql/server/src/middleware/__tests__/routing.test.ts b/graphql/server/src/middleware/__tests__/routing.test.ts index bcd013a888..5d8fb008b1 100644 --- a/graphql/server/src/middleware/__tests__/routing.test.ts +++ b/graphql/server/src/middleware/__tests__/routing.test.ts @@ -44,6 +44,7 @@ const matchedRoute = (overrides: Partial = {}): ResolvedRoute => verification_status: 'verified', tls_status: 'ready', tls_secret_name: 'tls-api-example-com', + runtime_site_id: 'site-1', ...overrides }); @@ -104,6 +105,7 @@ describe('routeToApiStructure', () => { expect(structure).toEqual( expect.objectContaining({ apiId: 'api-1', + siteId: 'site-1', databaseId: 'db-1', dbname: 'tenant_db', roleName: 'api_role', diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..e09e5189f0 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -224,6 +224,11 @@ const buildPreset = ( if (req.api?.apiId) { context['jwt.claims.api_id'] = req.api.apiId; } + // Independent trusted Site identity from scoped routing. A Site is + // not inferred from api_id because multiple Sites may share one API. + if (req.api?.siteId) { + context['jwt.claims.site_id'] = req.api.siteId; + } if (req.clientIp) { context['jwt.claims.ip_address'] = req.clientIp; } diff --git a/graphql/server/src/middleware/routing.ts b/graphql/server/src/middleware/routing.ts index 752b32bdff..10bf6da955 100644 --- a/graphql/server/src/middleware/routing.ts +++ b/graphql/server/src/middleware/routing.ts @@ -37,6 +37,8 @@ export interface ResolvedRoute { verification_status: string | null; tls_status: string | null; tls_secret_name: string | null; + /** Optional Site security context bound to this route independently of API. */ + runtime_site_id: string | null; } const RESOLVER_FUNCTION = 'resolve_route'; @@ -128,6 +130,7 @@ export const routeToApiStructure = ( return { apiId: config.api_id ?? route.target_source_id ?? undefined, + siteId: route.runtime_site_id ?? undefined, // Scoped APIs leave dbname NULL when their schemas live in the serving // database; fall back to the server's own database in that case. dbname: config.dbname || opts.pg?.database || '', diff --git a/packages/express-context/README.md b/packages/express-context/README.md index 72534d75cf..4927abda36 100644 --- a/packages/express-context/README.md +++ b/packages/express-context/README.md @@ -70,6 +70,24 @@ Each loader encapsulates a SQL query + type transform + per-databaseId LRU cache | `webauthnLoader` | `routing_public.webauthn_settings` | WebAuthn/passkey configuration | | `authSettingsLoader` | `metaschema_modules_public.sessions_module` | Cookie/captcha settings (two-step tenant DB discovery) | +### Opt-in authentication loaders + +`identityProvidersLoader` resolves enabled Tenant Provider configuration and +secrets. `ssoSurfaceLoader` resolves only the current database's provisioned +unified-auth private schema. Both are intentionally excluded from +`createDefaultRegistry()` and must be registered by the authentication service +that owns their cost and secret boundary: + +```typescript +const registry = createDefaultRegistry(); +registry.register(identityProvidersLoader); +registry.register(ssoSurfaceLoader); +``` + +`ssoSurfaceLoader` returns `undefined` when the current Tenant has no provisioned +unified-auth module. It never guesses a global `sso_private` schema or searches +another database. + ### Custom loaders ```typescript diff --git a/packages/express-context/__tests__/loaders/auth-loaders.test.ts b/packages/express-context/__tests__/loaders/auth-loaders.test.ts index f09e8e31fe..d23d0772e7 100644 --- a/packages/express-context/__tests__/loaders/auth-loaders.test.ts +++ b/packages/express-context/__tests__/loaders/auth-loaders.test.ts @@ -141,8 +141,22 @@ describe('identityProvidersLoader', () => { }; const provisioned = (rows: unknown[]) => [ - { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, - { rows: [{ schema_name: 'tenant_a_secrets', table_name: 'internal_secrets' }] }, + { + rows: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, + { + rows: [{ + schema_name: 'tenant_a_secrets', + table_name: 'internal_secrets', + scope: 'database', + prefix: '' + }] + }, { rows } ]; @@ -152,9 +166,10 @@ describe('identityProvidersLoader', () => { const module = await identityProvidersLoader.resolve(ctx(pool, 'db-a')); expect(calls[0].values).toEqual(['db-a']); - expect(calls[1].values).toEqual(['db-a']); - expect(calls[2].text).toContain('"tenant_a_secrets"."internal_secrets_get"'); + expect(calls[1].values).toEqual(['db-a', 'database']); + expect(calls[2].text).toContain('"tenant_a_secrets"."_internal_secrets_get"'); expect(calls[2].text).toContain('"tenant_a_auth_private"."identity_providers"'); + expect(calls[2].values).toEqual(['db-a']); expect(module?.providers.google).toMatchObject({ clientId: 'client-abc', clientSecret: 'shh', @@ -170,7 +185,14 @@ describe('identityProvidersLoader', () => { it('fails when the secret store is absent instead of yielding a secretless client', async () => { const { pool } = fakePool([ - { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, + { + rows: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, { rows: [] } ]); await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow( diff --git a/packages/express-context/__tests__/loaders/sso-surface.test.ts b/packages/express-context/__tests__/loaders/sso-surface.test.ts new file mode 100644 index 0000000000..92af9c3e39 --- /dev/null +++ b/packages/express-context/__tests__/loaders/sso-surface.test.ts @@ -0,0 +1,74 @@ +import type { Pool } from 'pg'; + +import { createDefaultRegistry } from '../../src/loaders'; +import { createLoaderRegistry } from '../../src/loaders/registry'; +import { ssoSurfaceLoader } from '../../src/loaders/sso-surface'; +import type { LoaderContext } from '../../src/loaders/types'; +import type { SsoSurface } from '../../src/types'; + +interface Call { + text: string; + values?: unknown[]; +} + +const fakePool = (rows: unknown[]) => { + const calls: Call[] = []; + const pool = { + query: jest.fn(async (text: string, values?: unknown[]) => { + calls.push({ text, values }); + return { rows }; + }) + } as unknown as Pool; + return { calls, pool }; +}; + +const ctx = (tenantPool: Pool, databaseId = 'db-1'): LoaderContext => ({ + routingPool: {} as Pool, + tenantPool, + databaseId, + dbname: 'tenant' +}); + +beforeEach(() => ssoSurfaceLoader.invalidate()); + +describe('ssoSurfaceLoader', () => { + it('resolves the database-scoped private schema from authoritative metadata', async () => { + const { calls, pool } = fakePool([ + { private_schema: 'tenant_a_sso_private' } + ]); + + const surface: SsoSurface | undefined = await ssoSurfaceLoader.resolve( + ctx(pool, 'db-a') + ); + + expect(surface).toEqual({ privateSchema: 'tenant_a_sso_private' }); + expect(calls).toHaveLength(1); + expect(calls[0].values).toEqual(['db-a']); + expect(calls[0].text).toMatch(/unified_auth\.database_id = \$1/); + expect(calls[0].text).toMatch(/unified_auth\.scope = 'database'/); + expect(calls[0].text).toMatch( + /private_schema\.id = unified_auth\.private_schema_id/ + ); + }); + + it('returns undefined when this Tenant has no provisioned module', async () => { + const { pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool))).resolves.toBeUndefined(); + }); + + it('does not run an unkeyed lookup without a database ID', async () => { + const { calls, pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool, ''))).rejects.toThrow( + /no databaseId/ + ); + expect(calls).toHaveLength(0); + }); + + it('is typed but remains explicitly opt-in', async () => { + expect(createDefaultRegistry().has('ssoSurface')).toBe(false); + + const registry = createLoaderRegistry(); + registry.register(ssoSurfaceLoader); + expect(registry.has('ssoSurface')).toBe(true); + }); +}); diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts index 6b87b83b15..590486d26f 100644 --- a/packages/express-context/__tests__/pg-settings.test.ts +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -3,6 +3,7 @@ import type { ApiStructure, ConstructiveAPIToken } from '../src/types'; const api: ApiStructure = { apiId: '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + siteId: '87763e7e-8aeb-4e5c-98ce-95e16b6f62ac', dbname: 'testdb', anonRole: 'anonymous', roleName: 'authenticated', @@ -17,6 +18,7 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); expect(settings['role']).toBe('anonymous'); }); @@ -42,10 +44,22 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { it('is derived only from the resolved api, never from the token', () => { const token = { user_id: 'u1', - api_id: 'attacker-controlled' + api_id: 'attacker-controlled', + site_id: 'attacker-controlled' } as unknown as ConstructiveAPIToken; const settings = buildPgSettings({ api, token, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); + }); + + it('omits jwt.claims.site_id when the route has no Site context', () => { + const settings = buildPgSettings({ + api: { ...api, siteId: undefined }, + token: null, + requestId: 'r1' + }); + + expect(settings['jwt.claims.site_id']).toBeUndefined(); }); }); diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 82d87de9d3..14eb8ad90f 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -7,7 +7,7 @@ * - pgSettings (role, claims, request_id, database_id) * - Tenant database pool (via pg-cache) * - withPgClient (transaction-scoped RLS helper) - * - Convenience fields (userId, databaseId, requestId) + * - Convenience fields (userId, databaseId, siteId, requestId) * - useModule (lazy, on-demand per-database module resolution) * * The result is a single `req.constructive` object that any downstream @@ -113,6 +113,7 @@ export function buildContext( token, pgSettings, databaseId: api.databaseId ?? null, + siteId: api.siteId ?? null, userId: token?.user_id ?? null, requestId, pool: tenantPool, diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 013e195f5d..5fe55920a4 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -56,6 +56,7 @@ export type { LlmConfig, PubkeyChallengeSettings, RlsModule, + SsoSurface, WebauthnSettings, WithPgClient, } from './types'; @@ -103,6 +104,7 @@ export { requireDatabaseId, requireIdentityProvider, rlsLoader, + ssoSurfaceLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 837a8292e3..ac44404280 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -28,7 +28,8 @@ import { requireDatabaseId } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.table_name AS table_name + SELECT s.schema_name AS schema_name, m.table_name AS table_name, + m.scope, m.prefix FROM metaschema_modules_public.identity_providers_module m JOIN metaschema_public.schema s ON s.id = m.private_schema_id WHERE m.database_id = $1 @@ -36,29 +37,37 @@ const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` `; const INTERNAL_SECRETS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name + SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name, + m.scope, m.prefix FROM metaschema_modules_public.internal_secrets_module m JOIN metaschema_public.schema s ON s.id = m.private_schema_id - WHERE m.database_id = $1 + WHERE m.database_id = $1 AND m.scope = $2 LIMIT 1 `; interface DiscoveredLocation { schema_name: string; table_name: string; + scope: string; + prefix: string; } /** * The providers query, with the tenant's own secret getter inlined. * - * The getter is `_get(name, namespace_id)` in the - * discovered store schema — the same function the auth procedures use, so a - * secret rotated through the platform's rotate verb is picked up with no - * further coordination. A provider whose `client_secret_id` is set but whose - * secret does not resolve yields `clientSecret: null`, which the caller must - * treat as a configuration fault rather than as a public client. + * The getter is the generated internal-secrets getter in the discovered store + * schema — the same function the auth procedures use, so a secret rotated + * through the platform's rotate verb is picked up with no further + * coordination. Database-scoped stores take the current database ID as their + * first argument; app/platform stores do not. A provider whose + * `client_secret_id` is set but whose secret does not resolve yields + * `clientSecret: null`, which the caller must treat as a configuration fault + * rather than as a public client. */ -const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredLocation) => ` +const buildProvidersQuery = ( + providers: DiscoveredLocation, + secrets: DiscoveredLocation +) => ` SELECT p.id, p.slug, @@ -68,7 +77,8 @@ const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredL p.client_id, CASE WHEN p.client_secret_id IS NULL THEN NULL - ELSE "${secrets.schema_name}"."${secrets.table_name}_get"( + ELSE "${secrets.schema_name}"."${secrets.prefix}_internal_secrets_get"( + ${secrets.scope === 'database' ? '$1,' : ''} p.slug || '/client-secret', uuid_nil() ) @@ -155,16 +165,16 @@ const toProviderConfig = (row: ProviderRow): IdentityProviderConfig => { const discoverOne = async ( ctx: LoaderContext, sql: string, - moduleName: string + values: unknown[] ): Promise => { - const result = await ctx.tenantPool.query(sql, [ctx.databaseId]); + const result = await ctx.tenantPool.query(sql, values); const row = result.rows[0]; if (!row?.schema_name || !row?.table_name) { // Not provisioned for this tenant — the loader contract's undefined. The // module name is kept in the debug trail rather than guessed at by callers. return undefined; } - return { schema_name: row.schema_name, table_name: row.table_name }; + return row; }; // ─── Loader ───────────────────────────────────────────────────────────────── @@ -184,14 +194,14 @@ export const identityProvidersLoader: ModuleLoader = const providers = await discoverOne( ctx, IDENTITY_PROVIDERS_DISCOVERY_SQL, - 'identity_providers_module' + [databaseId] ); if (!providers) return undefined; const secrets = await discoverOne( ctx, INTERNAL_SECRETS_DISCOVERY_SQL, - 'internal_secrets_module' + [databaseId, providers.scope] ); // A provider table without its secret store cannot yield a usable client // secret, and silently returning secret-less providers would present a @@ -203,7 +213,10 @@ export const identityProvidersLoader: ModuleLoader = ); } - const result = await tenantPool.query(buildProvidersQuery(providers, secrets)); + const result = await tenantPool.query( + buildProvidersQuery(providers, secrets), + secrets.scope === 'database' ? [databaseId] : [] + ); const bySlug: Record = {}; for (const row of result.rows) { diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a8a3203428..fc7081e870 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -16,6 +16,7 @@ * * Opt-in (not in the default registry, register it explicitly): * - identityProviders (three round trips, decrypts client secrets) + * - ssoSurface (current Tenant's provisioned unified-auth private schema) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -55,6 +56,7 @@ export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; export { rlsLoader } from './rls'; +export { ssoSurfaceLoader } from './sso-surface'; export { webauthnLoader } from './webauthn'; /** diff --git a/packages/express-context/src/loaders/sso-surface.ts b/packages/express-context/src/loaders/sso-surface.ts new file mode 100644 index 0000000000..212319214d --- /dev/null +++ b/packages/express-context/src/loaders/sso-surface.ts @@ -0,0 +1,45 @@ +/** + * Unified-auth SSO Surface Loader (Tier 2 — tenant DB) + * + * Resolves only the private schema provisioned for the current database's + * database-scoped unified_auth_module. Procedure names are fixed by the DB + * module contract; policy, Site configuration, and Provider secrets remain in + * their owning loaders/functions. + * + * This loader is opt-in and is not registered by createDefaultRegistry(). + */ + +import type { SsoSurface } from '../types'; +import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; + +const SSO_SURFACE_SQL = ` + SELECT private_schema.name AS private_schema + FROM metaschema_modules_public.unified_auth_module unified_auth + JOIN metaschema_public.schema private_schema + ON private_schema.id = unified_auth.private_schema_id + WHERE unified_auth.database_id = $1 + AND unified_auth.scope = 'database' + LIMIT 1 +`; + +interface SsoSurfaceRow { + private_schema: string; +} + +export const ssoSurfaceLoader: ModuleLoader = + createModuleLoader({ + name: 'ssoSurface', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'ssoSurface'); + + const result = await tenantPool.query(SSO_SURFACE_SQL, [ + databaseId + ]); + const row = result.rows[0]; + return row ? { privateSchema: row.private_schema } : undefined; + } + }); diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index cb86336456..65db1bf78b 100644 --- a/packages/express-context/src/pg-settings.ts +++ b/packages/express-context/src/pg-settings.ts @@ -64,6 +64,13 @@ export function buildPgSettings(input: PgSettingsInput): Record settings['jwt.claims.api_id'] = api.apiId; } + // Site provenance is an independent trusted routing fact. Multiple Sites + // may share an API, so it must never be reconstructed from api_id, Origin, + // Referer, or token claims. + if (api.siteId) { + settings['jwt.claims.site_id'] = api.siteId; + } + // Distributed tracing settings['request.id'] = requestId; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 4316018209..3da4434865 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -98,6 +98,11 @@ export interface AuthSurface { connectedAccountsView: string; } +/** Current Tenant's provisioned private unified-auth module surface. */ +export interface SsoSurface { + privateSchema: string; +} + /** One identity provider row, with its client secret resolved. */ export interface IdentityProviderConfig { id: string; @@ -136,6 +141,8 @@ export interface IdentityProvidersModule { export interface ApiStructure { apiId?: string; + /** Trusted Site runtime identity emitted by scoped routing, when present. */ + siteId?: string; dbname: string; anonRole: string; roleName: string; @@ -241,6 +248,7 @@ export interface BuiltinModuleMap { databaseSettings: DatabaseSettings; authSettings: AuthSettings; authSurface: AuthSurface; + ssoSurface: SsoSurface; identityProviders: IdentityProvidersModule; pubkeyChallengeSettings: PubkeyChallengeSettings; webauthnSettings: WebauthnSettings; @@ -274,6 +282,8 @@ export interface ConstructiveContext { pgSettings: Record; /** Database UUID from the API resolver */ databaseId: string | null; + /** Trusted Site UUID from the resolved route; never inferred from Origin. */ + siteId: string | null; /** Authenticated user ID from the JWT token */ userId: string | null; /** Per-request correlation ID for distributed tracing */