diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md new file mode 100644 index 0000000000..62b873c6b7 --- /dev/null +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -0,0 +1,31 @@ +# OAuth Implementation Follow-up + +## Identity Providers Module Scope + +- Investigate the platform `identity_providers_module` record whose `scope` is currently `app`. For platform-level OAuth configuration, the expected scope appears to be `platform`; keeping it as `app` may be a provisioning or migration artifact and can make platform OAuth semantics ambiguous. + +## Identity Auth Function Metadata + +- Consider making `sign_in_identity_function` and `sign_up_identity_function` metadata-driven. These functions are generated by the user auth module, but the current express-context config uses hardcoded function names. A future database/schema update could expose these function names through module metadata, similar to other auth functions. + +## Auth Settings Interval Parsing + +- Fix cookie/session duration parsing after the OAuth loader work lands. `app_settings_auth.cookie_max_age`, `remember_me_duration`, and `oauth_state_max_age` are PostgreSQL `interval` columns; `pg` returns them as `PostgresInterval` objects such as `{ days: 14 }` or `{ minutes: 10 }`. The current GraphQL server cookie helper still parses these values as second strings with `parseInt`, so loader-backed auth settings can silently fall back to defaults. This is an existing cookie auth settings compatibility issue exposed by the OAuth/auth settings loader path and should be handled in a follow-up PR. + +## Loader Cache Invalidation and TTL Semantics + +- Revisit `createModuleLoader` cache expiration and invalidation semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior should be decided together with explicit invalidation for writes performed by our own systems. + +- Known changes made through this process, such as admin APIs updating auth settings or identity providers, should invalidate the relevant loader cache after the database write succeeds. The current auth settings update path invalidates `authSettingsLoader`, but identity provider admin writes do not yet invalidate the cached OAuth provider config. Recommended shape: loaders own read, transform, cache, and invalidate; services or repositories own validate, authorize, write, audit, and post-write invalidate. For example, `identityProvidersService.updateProvider(ctx, input)` writes the provider config and then calls a targeted invalidation such as `registry.invalidate(ctx.databaseId, "identityProviders")` or the equivalent loader-specific invalidation API. + +- Unknown external changes, such as manual SQL, migrations, or another service updating module configuration, cannot be invalidated precisely by this process. Baseline approach: keep `updateAgeOnGet: false` so TTL remains a bounded staleness window, then reload from the database on the next read after TTL expiry. If stronger freshness is needed later, add an optional lightweight fingerprint probe near loader resolution, for example `getFingerprint(ctx)` using `updated_at`, version, or checksum plus `revalidateAfterMs` as the minimum interval between probes. This lets loaders detect external changes faster than full TTL expiry without fully reloading config on every request. + +- Recommendation: set `updateAgeOnGet` to `false`, add explicit post-write invalidation for known admin writes, and rely on TTL as the fallback for unknown external changes. Add fingerprint probing only if TTL-based staleness becomes too slow in practice. + +## API Service Cache Loader Snapshots + +- Revisit `graphql/server/src/middleware/api.ts` storing resolved loader values inside the cached `ApiStructure`. The API resolver currently resolves mutable module settings such as `authSettings`, `corsOrigins`, `databaseSettings`, `pubkeyChallengeSettings`, and `webauthnSettings`, then stores the whole API structure in `svcCache`, whose TTL is effectively long-lived. This can bypass each loader's own TTL and invalidation path; for example, `updateAuthSettings()` invalidates `authSettingsLoader`, but middleware that reads `req.api.authSettings` could still see the old value from `svcCache`. Consider narrowing `svcCache` to stable API routing fields only, resolving mutable module settings through `ctx.useModule(...)` at use sites, or adding coordinated `svcCache` invalidation whenever loader-backed settings are updated. + +## Env Config Consolidation + +- Consider moving existing CAPTCHA and upload environment variables into the shared `@pgpmjs/env` config surface in a separate cleanup PR. The reference OAuth branch added `RECAPTCHA_SECRET_KEY` and `MAX_UPLOAD_FILE_SIZE` to `PgpmOptions`, but this OAuth migration keeps those existing middleware paths on direct `process.env` reads to avoid widening the PR scope beyond OAuth/server admin APIs. diff --git a/docs/plan/oauth/oauth-local-e2e.md b/docs/plan/oauth/oauth-local-e2e.md new file mode 100644 index 0000000000..a30c238752 --- /dev/null +++ b/docs/plan/oauth/oauth-local-e2e.md @@ -0,0 +1,269 @@ +# OAuth Local E2E Test Steps + +This is the short local runbook for validating the OAuth runtime on `feat/oauth-reorg`. + +Do not commit real OAuth client secrets, `OAUTH_STATE_SECRET`, non-local database passwords, or reusable tokens. Export provider credentials from a local secret source. + +## 1. Use the Target Branch + +```bash +cd /Users/zeta/Projects/interweb/src/agents/constructive +git fetch origin +git checkout feat/oauth-reorg +git pull --ff-only +git status --short --branch +``` + +## 2. Prepare Local Secrets + +Prepare two groups of environment variables: + +- GraphQL server runtime: `OAUTH_STATE_SECRET`. The server reads this at startup to sign and verify `oauth_state` / `oauth_pkce` cookies. +- Provider initialization SQL: `GITHUB_OAUTH_*` and `GOOGLE_OAUTH_*`. These are passed into the provider setup `psql` scripts below, then written or rotated into the database. The GraphQL server does not read these provider credential env vars directly. + +```bash +# GraphQL server runtime. +export OAUTH_STATE_SECRET="$(openssl rand -hex 32)" + +# Provider initialization scripts only. +export GITHUB_OAUTH_CLIENT_ID="" +export GITHUB_OAUTH_CLIENT_SECRET="" + +export GOOGLE_OAUTH_CLIENT_ID="" +export GOOGLE_OAUTH_CLIENT_SECRET="" +``` + +Provider callback URLs: + +```text +GitHub: http://auth.localhost:3000/auth/github/callback +Google: http://localhost:3000/auth/google/callback +``` + +Google is tested through bare `localhost` because Google may reject `auth.localhost` as a local redirect URI. + +## 3. Rebuild the Local Database + +```bash +cd /Users/zeta/Projects/interweb/src/agents/constructive-db + +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate +eval "$(pgpm env)" + +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes + +dropdb --if-exists constructive +createdb constructive + +pgpm deploy --yes --database constructive --package constructive-local +``` + +## 4. Apply Local OAuth Test Setup + +`PGPASSWORD` in the setup commands is only the local PostgreSQL connection password for `psql`. + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive -v ON_ERROR_STOP=1 <<'SQL' +-- Local HTTP cookies. +UPDATE constructive_auth_private.app_settings_auth +SET allow_identity_sign_in = true, + allow_identity_sign_up = true, + oauth_require_verified_email = false, + cookie_secure = false; + +-- Google local callback alias: localhost -> auth API. +DELETE FROM services_public.domains +WHERE domain = 'localhost' + AND subdomain IS NULL; + +INSERT INTO services_public.domains (database_id, api_id, domain, subdomain, annotations) +SELECT database_id, id, 'localhost', NULL, + jsonb_build_object('purpose', 'local-google-oauth-callback') +FROM services_public.apis +WHERE name = 'auth' +LIMIT 1; +SQL +``` + +## 5. Build and Start the Server + +```bash +cd /Users/zeta/Projects/interweb/src/agents/constructive +pnpm install +pnpm build +``` + +```bash +# GraphQL server runtime environment. +PGHOST=localhost \ +PGPORT=5432 \ +PGUSER=postgres \ +PGPASSWORD=password \ +PGDATABASE=constructive \ +NODE_USE_ENV_PROXY=1 \ +NO_PROXY="localhost,127.0.0.1,::1,.localhost" \ +OAUTH_STATE_SECRET="$OAUTH_STATE_SECRET" \ +pnpm --filter @constructive-io/graphql-server dev +``` + +Expected: + +```text +listening at http://localhost:3000 +``` + +`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, and `PGDATABASE` are the GraphQL server's database connection settings for this local run. `OAUTH_STATE_SECRET` is the OAuth state-cookie signing secret. `NODE_USE_ENV_PROXY` and `NO_PROXY` control Node's outbound HTTP behavior. + +Keep `NODE_USE_ENV_PROXY=1` when the local network needs `HTTP_PROXY` / `HTTPS_PROXY`. Without it, Node's native `fetch` can fail during OAuth token exchange with `CALLBACK_FAILED` and `TypeError: fetch failed`. + +## 6. Configure Providers + +These commands use the provider initialization env vars from step 2. `psql` substitutes them into the setup script, and `rotate_identity_provider_platform_secret(...)` stores the provider client secret in the database. The GraphQL server later reads provider credentials through loaders and database metadata, not from `GITHUB_OAUTH_*` or `GOOGLE_OAUTH_*`. + +GitHub: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive \ + -v ON_ERROR_STOP=1 \ + -v github_client_id="$GITHUB_OAUTH_CLIENT_ID" \ + -v github_client_secret="$GITHUB_OAUTH_CLIENT_SECRET" <<'SQL' +INSERT INTO constructive_auth_private.identity_providers ( + slug, kind, display_name, enabled, client_id, + authorization_url, token_url, userinfo_url, scopes, pkce_enabled +) +VALUES ( + 'github', 'oauth2', 'GitHub', true, :'github_client_id', + 'https://github.com/login/oauth/authorize', + 'https://github.com/login/oauth/access_token', + 'https://api.github.com/user', ARRAY['read:user', 'user:email'], true +) +ON CONFLICT (slug) DO UPDATE +SET enabled = EXCLUDED.enabled, + client_id = EXCLUDED.client_id, + authorization_url = EXCLUDED.authorization_url, + token_url = EXCLUDED.token_url, + userinfo_url = EXCLUDED.userinfo_url, + scopes = EXCLUDED.scopes, + pkce_enabled = EXCLUDED.pkce_enabled; + +SELECT constructive_auth_private.rotate_identity_provider_platform_secret( + (SELECT id FROM constructive_auth_private.identity_providers WHERE slug = 'github'), + :'github_client_secret' +); +SQL +``` + +Google: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive \ + -v ON_ERROR_STOP=1 \ + -v google_client_id="$GOOGLE_OAUTH_CLIENT_ID" \ + -v google_client_secret="$GOOGLE_OAUTH_CLIENT_SECRET" <<'SQL' +INSERT INTO constructive_auth_private.identity_providers ( + slug, kind, display_name, enabled, client_id, + authorization_url, token_url, userinfo_url, scopes, pkce_enabled +) +VALUES ( + 'google', 'oauth2', 'Google', true, :'google_client_id', + 'https://accounts.google.com/o/oauth2/v2/auth', + 'https://oauth2.googleapis.com/token', + 'https://openidconnect.googleapis.com/v1/userinfo', + ARRAY['openid', 'email', 'profile'], true +) +ON CONFLICT (slug) DO UPDATE +SET enabled = EXCLUDED.enabled, + client_id = EXCLUDED.client_id, + authorization_url = EXCLUDED.authorization_url, + token_url = EXCLUDED.token_url, + userinfo_url = EXCLUDED.userinfo_url, + scopes = EXCLUDED.scopes, + pkce_enabled = EXCLUDED.pkce_enabled; + +SELECT constructive_auth_private.rotate_identity_provider_platform_secret( + (SELECT id FROM constructive_auth_private.identity_providers WHERE slug = 'google'), + :'google_client_secret' +); +SQL +``` + +Verify without printing secrets: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive -v ON_ERROR_STOP=1 <<'SQL' +SELECT slug, + enabled, + client_id IS NOT NULL AND client_id <> '' AS has_client_id, + client_secret_id IS NOT NULL AS has_client_secret, + scopes, + pkce_enabled +FROM constructive_auth_private.identity_providers +WHERE slug IN ('github', 'google') +ORDER BY slug; +SQL +``` + +## 7. Smoke Test + +```bash +curl --noproxy '*' http://auth.localhost:3000/auth/providers +curl --noproxy '*' http://localhost:3000/auth/providers +``` + +Expected: + +```json +{"providers":["github","google"]} +``` + +Authorization redirects: + +```bash +curl --noproxy '*' -i 'http://auth.localhost:3000/auth/github?redirect_uri=/auth/providers' +curl --noproxy '*' -i 'http://localhost:3000/auth/google?redirect_uri=/auth/providers' +``` + +Expected: + +- GitHub redirects to `https://github.com/login/oauth/authorize`. +- Google redirects to `https://accounts.google.com/o/oauth2/v2/auth`. +- Both responses set `oauth_state`. +- Both responses set `oauth_pkce` when `pkce_enabled = true`. + +## 8. Browser Test + +GitHub: + +```text +http://auth.localhost:3000/auth/github?redirect_uri=/auth/providers +``` + +Google: + +```text +http://localhost:3000/auth/google?redirect_uri=/auth/providers +``` + +Expected result: + +- Provider authorization completes. +- Browser redirects back to `/auth/providers`. +- The response shows the configured providers. +- Server logs include `Got profile`, `OAuth success`, and a successful cookie authentication. + +Optional DB check: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive -x -c " +SELECT service, + identifier IS NOT NULL AS has_identifier, + details->>'email' AS email, + is_verified, + created_at +FROM constructive_user_identifiers_private.connected_accounts +WHERE service IN ('github', 'google') +ORDER BY created_at DESC +LIMIT 10; +" +``` diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 3e27125d04..465109f92f 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -107,6 +107,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "useTx": false, }, }, + "oauth": {}, "pg": { "database": "config-db", "host": "override-host", diff --git a/graphql/server/package.json b/graphql/server/package.json index 73bceef6e0..0b83bbd4a2 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -45,6 +45,7 @@ "@constructive-io/express-context": "workspace:^", "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-types": "workspace:^", + "@constructive-io/oauth": "workspace:^", "@constructive-io/query-builder": "workspace:^", "@constructive-io/s3-utils": "workspace:^", "@constructive-io/url-domains": "workspace:^", @@ -53,6 +54,7 @@ "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", + "@pgsql/quotes": "^17.1.0", "agentic-server": "workspace:*", "cors": "^2.8.6", "deepmerge": "^4.3.1", diff --git a/graphql/server/src/middleware/__tests__/oauth.test.ts b/graphql/server/src/middleware/__tests__/oauth.test.ts new file mode 100644 index 0000000000..2a48af4e3a --- /dev/null +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -0,0 +1,360 @@ +import express from 'express'; +import http from 'http'; +import type { AddressInfo } from 'net'; +import { + createSignedState, + deriveCodeChallenge, + verifySignedState, +} from '@constructive-io/oauth'; + +import { createOAuthRoutes } from '../oauth'; + +const OAUTH_STATE_SECRET = 'test-oauth-state-secret'; +const originalFetch = global.fetch; +const authQueryMock = jest.fn(); + +jest.mock('@pgpmjs/env', () => ({ + getNodeEnv: jest.fn(() => 'test'), +})); + +jest.mock('@pgpmjs/logger', () => ({ + Logger: jest.fn(() => ({ + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + })), +})); + +interface TestHttpResponse { + statusCode: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +interface OAuthStatePayload { + redirect_uri: string; + provider: string; +} + +interface OAuthPkcePayload { + state: string; + provider: string; + code_verifier: string; +} + +const providerConfig = { + slug: 'github', + kind: 'oauth2' as const, + displayName: 'GitHub', + enabled: true, + clientId: 'github-client-id', + clientSecret: 'github-client-secret', + authorizationUrl: 'https://github.example.test/login/oauth/authorize', + tokenUrl: 'https://github.example.test/login/oauth/access_token', + userinfoUrl: 'https://github.example.test/api/v3/user', + scopes: ['read:user', 'user:email'], + authorizationParams: { + prompt: 'select_account', + }, + pkceEnabled: true, +}; + +afterEach(() => { + global.fetch = originalFetch; + authQueryMock.mockReset(); +}); + +function createConstructiveContext() { + return { + withPgClient: jest.fn(async (fn: (client: { query: typeof authQueryMock }) => Promise) => + fn({ query: authQueryMock }), + ), + useModule: jest.fn(async (name: string) => { + if (name === 'identityProviders') { + return { + providers: new Map([[providerConfig.slug, providerConfig]]), + }; + } + if (name === 'userAuthModule') { + return { + schemaName: 'constructive_auth_public', + identityFunctionSchemaName: 'constructive_auth_private', + signInIdentityFunction: 'sign_in_identity', + signUpIdentityFunction: 'sign_up_identity', + }; + } + if (name === 'authSettings') { + return { + cookieHttponly: true, + cookieSecure: false, + cookieSamesite: 'lax', + }; + } + if (name === 'connectedAccountsModule') { + return undefined; + } + return undefined; + }), + }; +} + +async function withOAuthServer( + run: (baseUrl: string) => Promise, +): Promise { + const app = express(); + app.use((req, _res, next) => { + (req as any).constructive = createConstructiveContext(); + next(); + }); + app.use('/auth', createOAuthRoutes({ + oauth: { + stateSecret: OAUTH_STATE_SECRET, + }, + } as any)); + + const server = await new Promise((resolve) => { + const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); + }); + + try { + const { port } = server.address() as AddressInfo; + return await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +async function request( + url: string, + headers: Record = {}, +): Promise { + return new Promise((resolve, reject) => { + const req = http.request(url, { method: 'GET', headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + res.on('end', () => { + resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }); + req.on('error', reject); + req.end(); + }); +} + +function getSetCookieValues(headers: http.IncomingHttpHeaders): string[] { + const setCookie = headers['set-cookie']; + if (!setCookie) return []; + return Array.isArray(setCookie) ? setCookie : [setCookie]; +} + +function readCookie(setCookies: string[], name: string): string { + const cookie = setCookies.find((value) => value.startsWith(`${name}=`)); + if (!cookie) { + throw new Error(`Missing ${name} cookie`); + } + const value = cookie.split(';')[0].slice(name.length + 1); + return decodeURIComponent(value); +} + +describe('OAuth routes', () => { + it('binds PKCE verifier to the signed state cookie without exposing it in the redirect URL', async () => { + await withOAuthServer(async (baseUrl) => { + const response = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`); + + expect(response.statusCode).toBe(302); + const location = response.headers.location; + expect(location).toBeDefined(); + + const redirect = new URL(location!); + const setCookies = getSetCookieValues(response.headers); + const stateCookie = readCookie(setCookies, 'oauth_state'); + const pkceCookie = readCookie(setCookies, 'oauth_pkce'); + + expect(redirect.origin).toBe('https://github.example.test'); + expect(redirect.pathname).toBe('/login/oauth/authorize'); + expect(redirect.searchParams.get('state')).toBe(stateCookie); + expect(redirect.searchParams.get('code_challenge_method')).toBe('S256'); + expect(redirect.searchParams.get('prompt')).toBe('select_account'); + expect(location).not.toContain('code_verifier'); + + const statePayload = verifySignedState(stateCookie, { + secret: OAUTH_STATE_SECRET, + }); + expect(statePayload).toMatchObject({ + redirect_uri: '/dashboard', + provider: 'github', + }); + + const pkcePayload = verifySignedState(pkceCookie, { + secret: OAUTH_STATE_SECRET, + }); + expect(pkcePayload).toMatchObject({ + state: stateCookie, + provider: 'github', + }); + expect(pkcePayload!.code_verifier).toHaveLength(43); + expect(redirect.searchParams.get('code_challenge')).toBe( + deriveCodeChallenge(pkcePayload!.code_verifier), + ); + + expect( + setCookies.find((value) => value.startsWith('oauth_state=')), + ).toContain('HttpOnly'); + expect( + setCookies.find((value) => value.startsWith('oauth_pkce=')), + ).toContain('HttpOnly'); + }); + }); + + it('rejects callback requests when the PKCE verifier is not bound to the returned state', async () => { + await withOAuthServer(async (baseUrl) => { + const stateCookie = createSignedState( + { redirect_uri: '/dashboard', provider: 'github' }, + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, + ); + const pkceCookie = createSignedState( + { + state: 'different-state', + provider: 'github', + code_verifier: 'test-code-verifier', + }, + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, + ); + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: [ + `oauth_state=${encodeURIComponent(stateCookie)}`, + `oauth_pkce=${encodeURIComponent(pkceCookie)}`, + ].join('; '), + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.pathname).toBe('/auth/error'); + expect(redirect.searchParams.get('error')).toBe('INVALID_PKCE'); + expect(redirect.searchParams.get('provider')).toBe('github'); + }); + }); + + it('rejects callback requests when signed state belongs to another provider', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + { redirect_uri: '/dashboard', provider: 'github' }, + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, + ); + const callbackUrl = new URL('/auth/google/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: `oauth_state=${encodeURIComponent(stateCookie)}`, + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.pathname).toBe('/auth/error'); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(redirect.searchParams.get('provider')).toBe('google'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('uses the identity function schema for successful sign-up callbacks', async () => { + await withOAuthServer(async (baseUrl) => { + const beginResponse = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`); + const setCookies = getSetCookieValues(beginResponse.headers); + const stateCookie = readCookie(setCookies, 'oauth_state'); + const pkceCookie = readCookie(setCookies, 'oauth_pkce'); + const pkcePayload = verifySignedState(pkceCookie, { + secret: OAUTH_STATE_SECRET, + }); + expect(pkcePayload).toBeTruthy(); + + global.fetch = jest.fn(async (url: string | URL, init?: RequestInit) => { + const urlString = url.toString(); + if (urlString === 'https://github.example.test/login/oauth/access_token') { + const body = JSON.parse(init?.body as string); + expect(body.code_verifier).toBe(pkcePayload!.code_verifier); + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ + access_token: 'provider-access-token', + token_type: 'bearer', + }), + text: jest.fn(), + } as unknown as Response; + } + if (urlString === 'https://github.example.test/api/v3/user') { + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ + id: 12345, + login: 'octocat', + email: 'octocat@example.test', + name: 'Octo Cat', + }), + text: jest.fn(), + } as unknown as Response; + } + if (urlString === 'https://github.example.test/api/v3/user/emails') { + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue([ + { + email: 'octocat@example.test', + primary: true, + verified: true, + }, + ]), + text: jest.fn(), + } as unknown as Response; + } + throw new Error(`Unexpected fetch URL: ${urlString}`); + }) as unknown as typeof fetch; + authQueryMock.mockResolvedValueOnce({ + rows: [ + { + access_token: 'constructive-session-token', + }, + ], + }); + + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + const callbackResponse = await request(callbackUrl.toString(), { + Cookie: [ + `oauth_state=${encodeURIComponent(stateCookie)}`, + `oauth_pkce=${encodeURIComponent(pkceCookie)}`, + ].join('; '), + }); + + expect(callbackResponse.statusCode).toBe(302); + expect(callbackResponse.headers.location).toBe('/dashboard'); + expect(authQueryMock).toHaveBeenCalledTimes(1); + expect(authQueryMock.mock.calls[0][0]).toContain( + 'constructive_auth_private.sign_up_identity', + ); + expect( + getSetCookieValues(callbackResponse.headers).some((cookie) => + cookie.startsWith('constructive_session='), + ), + ).toBe(true); + }); + }); +}); diff --git a/graphql/server/src/middleware/cookie.ts b/graphql/server/src/middleware/cookie.ts index bb92446396..69218e7154 100644 --- a/graphql/server/src/middleware/cookie.ts +++ b/graphql/server/src/middleware/cookie.ts @@ -24,10 +24,10 @@ export const getSessionCookieConfig = ( ): CookieConfig => { const DEFAULT_MAX_AGE = 86400; // 24 hours let maxAge = DEFAULT_MAX_AGE; - if (rememberMe && authSettings?.rememberMeDuration) { + if (rememberMe && typeof authSettings?.rememberMeDuration === 'string') { const parsed = parseInt(authSettings.rememberMeDuration, 10); if (!isNaN(parsed)) maxAge = parsed; - } else if (authSettings?.cookieMaxAge) { + } else if (typeof authSettings?.cookieMaxAge === 'string') { const parsed = parseInt(authSettings.cookieMaxAge, 10); if (!isNaN(parsed)) maxAge = parsed; } diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts new file mode 100644 index 0000000000..af3186dc9e --- /dev/null +++ b/graphql/server/src/middleware/oauth.ts @@ -0,0 +1,656 @@ +/** + * OAuth / SSO Middleware + * + * Express router for OAuth2/OIDC identity-based sign-in. Uses module loaders + * from @constructive-io/express-context to discover schemas and config at + * runtime rather than hardcoding assumptions about where tables live. + * + * Resolves per-database: + * - identityProviders → schema where identity_providers table lives + * - userAuthModule → schema + function names for sign_in_identity / sign_up_identity + * - authSettings → cookie, captcha, and session config + * - connectedAccountsModule → schema for OAuth identity associations + * + * All DB queries run through `req.constructive.withPgClient()` which + * applies pgSettings (role, claims, request_id) via SET LOCAL, replacing + * the manual `set_config()` calls in the original implementation. + */ + +import { Router, Request, Response } from 'express'; +import { + OAuthClient, + OAuthProfile, + createSignedState, + verifySignedState, +} from '@constructive-io/oauth'; +import { Logger } from '@pgpmjs/logger'; +import { getNodeEnv } from '@pgpmjs/env'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import type { + AuthSettings, + ConnectedAccountsModuleConfig, + ConstructiveContext, + IdentityProvidersConfig, + IdentityProviderFullConfig, + UserAuthModuleConfig, +} from '@constructive-io/express-context'; + +import { + DEVICE_TOKEN_COOKIE_NAME, + getSessionCookieConfig, + getDeviceTokenCookieConfig, + setSessionCookie, + setDeviceTokenCookie, + parseCookieValue, +} from './cookie'; +import { pgIntervalToMilliseconds } from '../utils/pg-interval'; + +const log = new Logger('oauth'); + +const OAUTH_STATE_COOKIE = 'oauth_state'; +const OAUTH_PKCE_COOKIE = 'oauth_pkce'; +const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes +const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error'; + +interface OAuthStatePayload { + redirect_uri: string; + provider: string; +} + +interface OAuthPkcePayload { + state: string; + provider: string; + code_verifier: string; +} + +function getStateSecret(opts: ConstructiveOptions): string | undefined { + return opts.oauth?.stateSecret; +} + +function requireStateSecret(opts: ConstructiveOptions): string { + const secret = getStateSecret(opts); + if (!secret) { + throw new Error('OAUTH_STATE_SECRET environment variable is required'); + } + return secret; +} + +// ============================================================================= +// Module Resolution Helpers +// ============================================================================= + +interface OAuthModules { + identityProviders: IdentityProvidersConfig; + userAuthModule: UserAuthModuleConfig; + authSettings: AuthSettings | undefined; + connectedAccountsModule: ConnectedAccountsModuleConfig | undefined; +} + +async function resolveOAuthModules( + ctx: ConstructiveContext, +): Promise { + const [ + identityProviders, + userAuthModule, + authSettings, + connectedAccountsModule, + ] = await Promise.all([ + ctx.useModule('identityProviders'), + ctx.useModule('userAuthModule'), + ctx.useModule('authSettings'), + ctx.useModule('connectedAccountsModule'), + ]); + + if (!identityProviders || !userAuthModule) { + return null; + } + + return { + identityProviders, + userAuthModule, + authSettings, + connectedAccountsModule, + }; +} + +// ============================================================================= +// OAuth Client Factory +// ============================================================================= + +function createOAuthClientForProvider( + providerConfig: IdentityProviderFullConfig, + baseUrl: string, +): OAuthClient { + return new OAuthClient({ + providers: { + [providerConfig.slug]: { + slug: providerConfig.slug, + kind: providerConfig.kind, + displayName: providerConfig.displayName, + enabled: providerConfig.enabled, + clientId: providerConfig.clientId, + clientSecret: providerConfig.clientSecret, + authorizationUrl: providerConfig.authorizationUrl, + tokenUrl: providerConfig.tokenUrl, + userinfoUrl: providerConfig.userinfoUrl, + scopes: providerConfig.scopes, + authorizationParams: providerConfig.authorizationParams, + pkceEnabled: providerConfig.pkceEnabled, + }, + }, + baseUrl, + callbackPath: '/auth/{provider}/callback', + }); +} + +interface SignInIdentityResult { + id?: string; + user_id?: string; + access_token?: string; + access_token_expires_at?: string; + is_verified?: boolean; + totp_enabled?: boolean; + mfa_required?: boolean; + mfa_challenge_token?: string; + out_device_token?: string; +} + +// ============================================================================= +// OAuth Routes +// ============================================================================= + +function getBaseUrl(req: Request): string { + const protocol = req.protocol || 'http'; + const host = req.get('host') || 'localhost:3000'; + return `${protocol}://${host}`; +} + +function normalizeRedirectUri( + redirectUri: string | undefined, + baseUrl: string, +): string | null { + const requestedRedirectUri = redirectUri || '/'; + + try { + const url = new URL(requestedRedirectUri, baseUrl); + if (url.origin !== new URL(baseUrl).origin) return null; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +} + +/** + * Check if the user's email is verified by the OAuth provider. + */ +function isEmailVerified(profile: OAuthProfile): boolean { + return profile.emailVerified === true; +} + +function redirectToError( + res: Response, + baseUrl: string, + errorPath: string, + error: string, + provider: string, + errorDescription?: string, +): void { + const errorUrl = new URL(errorPath, baseUrl); + errorUrl.searchParams.set('error', error); + errorUrl.searchParams.set('provider', provider); + if (errorDescription) { + errorUrl.searchParams.set('error_description', errorDescription); + } + res.redirect(errorUrl.toString()); +} + +export function createOAuthRoutes(opts: ConstructiveOptions): Router { + const router = Router(); + const isProduction = getNodeEnv() === 'production'; + + // GET /auth/providers - List available providers from database + router.get('/providers', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.json({ providers: [] }); + } + + try { + const modules = await resolveOAuthModules(ctx); + if (!modules) { + return res.json({ providers: [] }); + } + // Get all enabled provider slugs from the cached config map + const providers = Array.from(modules.identityProviders.providers.keys()); + res.json({ providers }); + } catch (error) { + log.error('[oauth] Failed to fetch providers:', error); + res.json({ providers: [] }); + } + }); + + // GET /auth/error - Pass to next middleware stack for frontend to handle + router.get('/error', (_req: Request, _res: Response, next) => { + next('router'); + }); + + // GET /auth/:provider - Initiate OAuth flow + router.get('/:provider', async (req: Request, res: Response) => { + const { provider } = req.params; + const requestedRedirectUri = + typeof req.query.redirect_uri === 'string' + ? req.query.redirect_uri + : undefined; + const ctx = req.constructive; + const baseUrl = getBaseUrl(req); + + if (!ctx) { + log.error(`[oauth] No constructive context for ${provider} initiation`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'API_NOT_CONFIGURED', + provider, + ); + } + + try { + const modules = await resolveOAuthModules(ctx); + if (!modules) { + log.error(`[oauth] Required modules not provisioned for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'MODULES_NOT_CONFIGURED', + provider, + ); + } + + const { authSettings, identityProviders } = modules; + const errorRedirectPath = + authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + + const redirectUri = normalizeRedirectUri(requestedRedirectUri, baseUrl); + if (!redirectUri) { + log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'INVALID_REDIRECT_URI', + provider, + ); + } + + // Get provider config from cached map + const providerConfig = identityProviders.providers.get(provider); + if (!providerConfig) { + log.warn(`[oauth] Provider ${provider} not found or not configured`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'PROVIDER_NOT_CONFIGURED', + provider, + ); + } + + const stateMaxAge = + pgIntervalToMilliseconds(authSettings?.oauthStateMaxAge) ?? + DEFAULT_OAUTH_STATE_MAX_AGE; + const state = createSignedState( + { redirect_uri: redirectUri, provider }, + { + secret: requireStateSecret(opts), + maxAgeMs: stateMaxAge, + }, + ); + + const oauthCookieOptions = { + httpOnly: authSettings?.cookieHttponly ?? true, + secure: authSettings?.cookieSecure ?? isProduction, + maxAge: stateMaxAge, + sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax', + }; + + res.cookie(OAUTH_STATE_COOKIE, state, oauthCookieOptions); + + const client = createOAuthClientForProvider(providerConfig, baseUrl); + const { url, codeVerifier } = client.getAuthorizationUrl({ provider, state }); + if (codeVerifier) { + const pkceState = createSignedState( + { state, provider, code_verifier: codeVerifier }, + { + secret: requireStateSecret(opts), + maxAgeMs: stateMaxAge, + }, + ); + res.cookie(OAUTH_PKCE_COOKIE, pkceState, oauthCookieOptions); + } + log.info(`[oauth] Initiating OAuth flow for provider: ${provider}`); + res.redirect(url); + } catch (error) { + log.error(`[oauth] Failed to initiate OAuth for ${provider}:`, error); + redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'OAUTH_INIT_FAILED', + provider, + ); + } + }); + + // GET /auth/:provider/callback - Handle OAuth callback + router.get( + '/:provider/callback', + async (req: Request, res: Response) => { + const { provider } = req.params; + const { + code, + state, + error: oauthError, + error_description: errorDescription, + } = req.query; + const baseUrl = getBaseUrl(req); + + const storedState = parseCookieValue(req, OAUTH_STATE_COOKIE); + const storedPkce = parseCookieValue(req, OAUTH_PKCE_COOKIE); + res.clearCookie(OAUTH_STATE_COOKIE); + res.clearCookie(OAUTH_PKCE_COOKIE); + + // Handle OAuth provider errors + if (oauthError) { + log.warn(`[oauth] Provider ${provider} returned error: ${oauthError}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + oauthError as string, + provider, + errorDescription as string | undefined, + ); + } + + // Verify state + if (state !== storedState) { + log.warn(`[oauth] State mismatch for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider, + ); + } + + const statePayload = verifySignedState( + storedState as string, + { secret: getStateSecret(opts) }, + ); + if (!statePayload) { + log.warn(`[oauth] Invalid or expired state for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider, + ); + } + + if (statePayload.provider !== provider) { + log.warn(`[oauth] State provider mismatch for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider, + ); + } + + const { redirect_uri: redirectUriFromState } = statePayload; + const ctx = req.constructive; + + if (!ctx) { + log.error( + `[oauth] No constructive context for ${provider} callback`, + ); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'API_NOT_CONFIGURED', + provider, + ); + } + + let modules: OAuthModules | null = null; + try { + modules = await resolveOAuthModules(ctx); + if (!modules) { + log.error( + `[oauth] Required modules not provisioned for ${provider}`, + ); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'MODULES_NOT_CONFIGURED', + provider, + ); + } + + const { authSettings, identityProviders } = modules; + const errorRedirectPath = + authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + const requireVerifiedEmail = + authSettings?.oauthRequireVerifiedEmail ?? true; + + const redirectUri = normalizeRedirectUri(redirectUriFromState, baseUrl); + if (!redirectUri) { + log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'INVALID_REDIRECT_URI', + provider, + ); + } + + // Get provider config from cached map + const providerConfig = identityProviders.providers.get(provider); + if (!providerConfig) { + log.error(`[oauth] Provider ${provider} not found in database`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'PROVIDER_NOT_CONFIGURED', + provider, + ); + } + + let codeVerifier: string | undefined; + if (providerConfig.pkceEnabled) { + const pkcePayload = verifySignedState( + storedPkce, + { secret: getStateSecret(opts) }, + ); + if ( + !pkcePayload || + pkcePayload.state !== storedState || + pkcePayload.provider !== provider || + !pkcePayload.code_verifier + ) { + log.warn(`[oauth] Invalid PKCE verifier state for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'INVALID_PKCE', + provider, + ); + } + codeVerifier = pkcePayload.code_verifier; + } + + const client = createOAuthClientForProvider(providerConfig, baseUrl); + const profile = await client.handleCallback({ + provider, + code: code as string, + codeVerifier, + }); + log.info(`[oauth] Got profile for ${provider}: ${profile.email}`); + + const deviceToken = + parseCookieValue(req, DEVICE_TOKEN_COOKIE_NAME) ?? null; + + const userAgent = req.get('user-agent') || ''; + const { connectedAccountsModule, userAuthModule } = modules; + const authPrivateSchema = userAuthModule.identityFunctionSchemaName; + const signInFn = userAuthModule.signInIdentityFunction; + const signUpFn = userAuthModule.signUpIdentityFunction; + const emailVerified = isEmailVerified(profile); + + // Check if identity already exists via connectedAccounts loader + // This determines whether to sign_in or sign_up, avoiding SAVEPOINT/rollback + let identityExists = false; + if (connectedAccountsModule) { + const checkSql = ` + SELECT 1 FROM ${QuoteUtils.quoteQualifiedIdentifier(connectedAccountsModule.privateSchemaName, connectedAccountsModule.tableName)} + WHERE service = $1 AND identifier = $2 + LIMIT 1 + `; + // Intentional RLS bypass: pre-auth lookup for anonymous user who cannot query + // connected_accounts via RLS. Only checks existence by service+identifier. + const checkResult = await ctx.pool.query(checkSql, [ + profile.provider, + profile.providerId, + ]); + identityExists = checkResult.rowCount > 0; + log.info( + `[oauth] Identity check for ${profile.email}: ${identityExists ? 'exists' : 'new'}`, + ); + } + + // If new identity, check email verification requirement before proceeding + if (!identityExists && requireVerifiedEmail && !emailVerified) { + throw new Error('EMAIL_NOT_VERIFIED'); + } + + const result = await ctx.withPgClient( + async (client) => { + const details = { + provider: profile.provider, + sub: profile.providerId, + email: profile.email, + email_verified: emailVerified, + name: profile.name, + picture: profile.picture, + raw_userinfo: profile.raw, + }; + + if (identityExists) { + // Sign in existing identity + const signInSql = ` + SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(authPrivateSchema, signInFn)}( + $1::text, $2::text, $3::jsonb, $4::text, 'access_token'::text, $5::boolean, $6::text + ) + `; + const signInResult = await client.query(signInSql, [ + profile.provider, + profile.providerId, + JSON.stringify(details), + profile.email, + true, + deviceToken, + ]); + return signInResult.rows[0] || {}; + } else { + // Sign up new identity + log.info( + `[oauth] Creating new account for ${profile.email}`, + ); + const signUpSql = ` + SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(authPrivateSchema, signUpFn)}( + $1::text, $2::text, $3::text, $4::jsonb, 'access_token'::text, $5::boolean, $6::text + ) + `; + const signUpResult = await client.query(signUpSql, [ + profile.provider, + profile.providerId, + profile.email, + JSON.stringify(details), + true, + deviceToken, + ]); + return signUpResult.rows[0] || {}; + } + }, + { + 'jwt.claims.user_agent': userAgent, + 'jwt.claims.origin': baseUrl, + }, + ); + + // Handle MFA required + if (result.mfa_required && result.mfa_challenge_token) { + log.info(`[oauth] MFA required for ${profile.email}`); + const mfaUrl = new URL('/auth/mfa', baseUrl); + mfaUrl.searchParams.set('token', result.mfa_challenge_token); + mfaUrl.searchParams.set('redirect_uri', redirectUri); + return res.redirect(mfaUrl.toString()); + } + + if (!result.access_token) { + throw new Error('No access token returned from sign_in_identity'); + } + + const sessionConfig = getSessionCookieConfig( + modules.authSettings, + true, + ); + setSessionCookie(res, result.access_token, sessionConfig); + + if (result.out_device_token) { + const deviceConfig = getDeviceTokenCookieConfig( + modules.authSettings, + ); + setDeviceTokenCookie(res, result.out_device_token, deviceConfig); + } + + log.info(`[oauth] OAuth success for ${profile.email}`); + return res.redirect(redirectUri); + } catch (error: any) { + const fallbackPath = + modules?.authSettings?.oauthErrorRedirectPath || + DEFAULT_ERROR_REDIRECT_PATH; + + // Handle specific error cases + if (error.message === 'EMAIL_NOT_VERIFIED') { + log.warn( + `[oauth] Rejecting unverified email for signup: ${provider}`, + ); + return redirectToError( + res, + baseUrl, + fallbackPath, + 'EMAIL_NOT_VERIFIED', + provider, + ); + } + + log.error(`[oauth] Callback failed for ${provider}:`, error); + redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider); + } + }, + ); + + return router; +} diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 6f08fd7025..f65c941fd4 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -39,6 +39,7 @@ import { createRequestLogger } from './middleware/observability/request-logger'; import { createCaptchaMiddleware } from './middleware/captcha'; import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { createAgenticRouter } from 'agentic-server'; +import { createOAuthRoutes } from './middleware/oauth'; import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -197,6 +198,10 @@ class Server { app.use(csrfSetToken); // Set CSRF token cookie on all requests app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations + // OAuth / SSO routes — mounted before graphile so OAuth callbacks + // are handled without going through PostGraphile + app.use('/auth', createOAuthRoutes(effectiveOpts)); + // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile app.use(createAgenticRouter()); diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index 474ad0fc2d..12435f624a 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -11,6 +11,7 @@ export type { CorsModuleData, DatabaseSettings, GenericModuleData, + PgInterval, PubkeyChallengeSettings, PublicKeyChallengeData, RlsModule, diff --git a/graphql/server/src/utils/pg-interval.ts b/graphql/server/src/utils/pg-interval.ts new file mode 100644 index 0000000000..c198696ddd --- /dev/null +++ b/graphql/server/src/utils/pg-interval.ts @@ -0,0 +1,29 @@ +import type { PgInterval } from '@constructive-io/express-context'; + +/** + * Convert a PostgreSQL interval value from auth settings into milliseconds. + * + * Numeric string values are treated as seconds, matching the existing auth + * settings cookie parser behavior. + */ +export function pgIntervalToMilliseconds( + interval: string | PgInterval | null | undefined, +): number | null { + if (!interval) return null; + + if (typeof interval === 'string') { + const seconds = parseInt(interval, 10); + return Number.isNaN(seconds) ? null : seconds * 1000; + } + + let totalSeconds = 0; + if (interval.years) totalSeconds += interval.years * 365 * 24 * 60 * 60; + if (interval.months) totalSeconds += interval.months * 30 * 24 * 60 * 60; + if (interval.days) totalSeconds += interval.days * 24 * 60 * 60; + if (interval.hours) totalSeconds += interval.hours * 60 * 60; + if (interval.minutes) totalSeconds += interval.minutes * 60; + if (interval.seconds) totalSeconds += interval.seconds; + if (interval.milliseconds) totalSeconds += interval.milliseconds / 1000; + + return totalSeconds > 0 ? totalSeconds * 1000 : null; +} diff --git a/packages/express-context/package.json b/packages/express-context/package.json index 14032875c5..2a892882ad 100644 --- a/packages/express-context/package.json +++ b/packages/express-context/package.json @@ -34,6 +34,7 @@ "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", + "@pgsql/quotes": "^17.1.0", "lru-cache": "^11.2.7", "pg": "^8.21.0", "pg-cache": "workspace:^", diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 89639fa078..34c968fcda 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -96,8 +96,15 @@ export function buildContext( }; } - const withPgClient = (fn: (client: any) => Promise) => - withPgClientFn(tenantPool, pgSettings, fn); + const withPgClient = ( + fn: (client: any) => Promise, + pgSettingsOverrides?: Record + ) => + withPgClientFn( + tenantPool, + pgSettingsOverrides ? { ...pgSettings, ...pgSettingsOverrides } : pgSettings, + fn + ); const useModule = createUseModule(opts.loaders, loaderCtx); // Lazy-initialized billing client (cached per request) diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 2e8c5697ce..3b0f08ba9d 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -45,6 +45,7 @@ export type { AuthSettings, BillingConfig, BuiltinModuleMap, + ConnectedAccountsModuleConfig, ComputeConfig, ComputeModuleConfig, ConstructiveAPIToken, @@ -52,11 +53,16 @@ export type { CorsModuleData, DatabaseSettings, GenericModuleData, + IdentityProviderConfigMap, + IdentityProviderFullConfig, + IdentityProvidersConfig, InferenceLogConfig, + PgInterval, LlmConfig, PublicKeyChallengeData, PubkeyChallengeSettings, RlsModule, + UserAuthModuleConfig, WebauthnSettings, WithPgClient, } from './types'; @@ -90,15 +96,18 @@ export { agentChatLoader, authSettingsLoader, billingLoader, + connectedAccountsModuleLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, + identityProvidersLoader, inferenceLogLoader, pubkeyLoader, rlsLoader, + userAuthModuleLoader, llmLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts new file mode 100644 index 0000000000..80f7397e3c --- /dev/null +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -0,0 +1,235 @@ +import type { LoaderContext } from '../types'; +import { + buildProvidersSql, + resolveIdentityProvidersConfig, +} from '../identity-providers'; +import { userAuthModuleLoader } from '../user-auth-module'; + +type QueryResult = { rows: unknown[] }; +type QueryHandler = (sql: string, values?: unknown[]) => QueryResult; + +function createMockPool(handlers: QueryHandler[]) { + const queries: Array<{ sql: string; values?: unknown[] }> = []; + const query = jest.fn(async (sql: string, values?: unknown[]) => { + queries.push({ sql, values }); + const handler = handlers.shift(); + if (!handler) { + throw new Error(`Unexpected query: ${sql}`); + } + return handler(sql, values); + }); + + return { + pool: { query }, + queries, + }; +} + +function createContext( + servicesPool: ReturnType['pool'], + tenantPool: ReturnType['pool'], + databaseId = 'tenant-db', +): LoaderContext { + return { + servicesPool, + tenantPool, + databaseId, + dbname: 'constructive-test', + } as unknown as LoaderContext; +} + +function identityProvidersModuleRow(databaseId: string, scope: string) { + return { + database_id: databaseId, + schema_name: 'auth_public', + private_schema_name: 'auth_private', + table_name: 'identity_providers', + scope, + prefix: scope, + }; +} + +describe('identityProvidersLoader metadata resolution', () => { + it('resolves provider secrets table through internal_secrets_module schema metadata', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ rows: [{ internal_secrets_table_id: 'secrets-table-id' }] }), + () => ({ + rows: [ + { + schema_name: 'secret_private', + table_name: 'resolved_secrets', + }, + ], + }), + () => ({ + rows: [ + { + slug: 'github', + kind: 'oauth2', + display_name: 'GitHub', + enabled: true, + client_id: 'dummy-client-id', + client_secret: 'dummy-client-secret', + authorization_url: 'https://github.example/authorize', + token_url: 'https://github.example/token', + userinfo_url: 'https://github.example/user', + scopes: ['read:user'], + extra_authorization_params: { prompt: 'select_account' }, + pkce_enabled: true, + }, + ], + }), + ]); + + const config = await resolveIdentityProvidersConfig( + createContext(services.pool, tenant.pool), + ); + + expect(config?.providers.get('github')).toMatchObject({ + clientId: 'dummy-client-id', + clientSecret: 'dummy-client-secret', + authorizationUrl: 'https://github.example/authorize', + authorizationParams: { prompt: 'select_account' }, + }); + expect(services.queries[0].values).toEqual(['constructive-test']); + expect(services.queries[2].sql).toContain('internal_secrets_module'); + expect(services.queries[2].values).toEqual(['platform-db', 'platform']); + expect(services.queries[4].sql).toContain('secret_private.resolved_secrets'); + expect(services.queries[4].sql).not.toContain( + 'constructive_store_private', + ); + expect(services.queries[4].sql).not.toContain('platform_secrets'); + }); + + it('builds provider SQL without the old platform secrets hardcode', () => { + const sql = buildProvidersSql( + 'auth_private', + 'identity_providers', + 'secret_private', + 'resolved_secrets', + ); + + expect(sql).toContain('auth_private.identity_providers'); + expect(sql).toContain('secret_private.resolved_secrets'); + expect(sql).not.toContain('constructive_store_private'); + expect(sql).not.toContain('platform_secrets'); + }); + + it('throws a clear error when internal_secrets_module is missing for scope', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ rows: [] }), + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)), + ).rejects.toThrow( + 'internal_secrets_module missing for scope platform on database platform-db', + ); + }); + + it('throws a clear error when internal_secrets_module table resolution fails', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ rows: [{ internal_secrets_table_id: 'missing-table-id' }] }), + () => { + throw new Error('NOT_FOUND'); + }, + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)), + ).rejects.toThrow( + 'schema/table resolution missing for internal_secrets_module scope platform on database platform-db', + ); + }); +}); + +describe('userAuthModuleLoader', () => { + afterEach(() => { + userAuthModuleLoader.invalidate(); + }); + + it('continues resolving identity auth function constants', async () => { + const tenant = createMockPool([ + () => ({ + rows: [ + { + schema_name: 'auth_public', + session_credentials_schema_name: 'session_private', + sign_in_function: 'sign_in', + sign_up_function: 'sign_up', + sign_out_function: 'sign_out', + sign_in_cross_origin_function: null, + request_cross_origin_token_function: null, + extend_token_expires: '1 hour', + }, + ], + }), + () => ({ + rows: [ + { + schema_name: 'auth_private', + }, + ], + }), + ]); + const services = createMockPool([]); + + const config = await userAuthModuleLoader.resolve( + createContext(services.pool, tenant.pool, 'user-auth-db'), + ); + + expect(config).toMatchObject({ + schemaName: 'auth_public', + identityFunctionSchemaName: 'auth_private', + sessionCredentialsSchemaName: 'session_private', + signInIdentityFunction: 'sign_in_identity', + signUpIdentityFunction: 'sign_up_identity', + }); + }); + + it('falls back to the public auth schema when identity function schema is not discoverable', async () => { + const tenant = createMockPool([ + () => ({ + rows: [ + { + schema_name: 'auth_public', + session_credentials_schema_name: null, + sign_in_function: 'sign_in', + sign_up_function: 'sign_up', + sign_out_function: 'sign_out', + sign_in_cross_origin_function: null, + request_cross_origin_token_function: null, + extend_token_expires: '1 hour', + }, + ], + }), + () => ({ rows: [] }), + ]); + const services = createMockPool([]); + + const config = await userAuthModuleLoader.resolve( + createContext(services.pool, tenant.pool, 'fallback-user-auth-db'), + ); + + expect(config).toMatchObject({ + schemaName: 'auth_public', + identityFunctionSchemaName: 'auth_public', + sessionCredentialsSchemaName: 'auth_public', + }); + }); +}); diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 52fc803072..258743dd40 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -10,7 +10,13 @@ * database rather than the services database. */ -import type { AuthSettings } from '../types'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { Pool } from 'pg'; + +import type { + AuthSettings, + AuthSettingsRow, +} from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -20,36 +26,59 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` SELECT s.schema_name, sm.auth_settings_table_name AS table_name FROM metaschema_modules_public.sessions_module sm JOIN metaschema_public.schema s ON s.id = sm.schema_id + WHERE sm.database_id = $1 LIMIT 1 `; -const buildAuthSettingsQuery = (schemaName: string, tableName: string) => ` - SELECT - cookie_secure, - cookie_samesite, - cookie_domain, - cookie_httponly, - cookie_max_age, - cookie_path, - remember_me_duration, - enable_captcha, - captcha_site_key - FROM "${schemaName}"."${tableName}" - LIMIT 1 -`; +interface AuthSettingsTableRef { + schemaName: string; + tableName: string; +} + +async function discoverAuthSettingsTable( + pool: Pool, + databaseId: string | null | undefined, +): Promise { + if (!databaseId) return null; -// ─── Row Types ────────────────────────────────────────────────────────────── + const discovery = await pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + [databaseId], + ); + const resolved = discovery.rows[0]; + if (!resolved) return null; -interface AuthSettingsRow { - cookie_secure: boolean; - cookie_samesite: string; - cookie_domain: string | null; - cookie_httponly: boolean; - cookie_max_age: string | null; - cookie_path: string; - remember_me_duration: string | null; - enable_captcha: boolean; - captcha_site_key: string | null; + return { + schemaName: resolved.schema_name, + tableName: resolved.table_name, + }; +} + +function buildAuthSettingsQuery(schemaName: string, tableName: string): string { + const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( + schemaName, + tableName, + ); + + return ` + SELECT + allow_identity_sign_in, + allow_identity_sign_up, + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age, + cookie_path, + remember_me_duration, + enable_captcha, + captcha_site_key, + oauth_state_max_age, + oauth_require_verified_email, + oauth_error_redirect_path + FROM ${authSettingsTable} + LIMIT 1 + `; } // ─── Loader ───────────────────────────────────────────────────────────────── @@ -58,23 +87,20 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader name: 'authSettings', ttlMs: 5 * 60_000, async resolve(ctx: LoaderContext) { - const { tenantPool } = ctx; + const { tenantPool, databaseId } = ctx; - // Step 1: Discover schema + table from sessions_module - const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - ); - const resolved = discovery.rows[0]; + const resolved = await discoverAuthSettingsTable(tenantPool, databaseId); if (!resolved) return undefined; - // Step 2: Query the actual auth settings table const result = await tenantPool.query( - buildAuthSettingsQuery(resolved.schema_name, resolved.table_name), + buildAuthSettingsQuery(resolved.schemaName, resolved.tableName), ); const row = result.rows[0]; if (!row) return undefined; return { + allowIdentitySignIn: row.allow_identity_sign_in, + allowIdentitySignUp: row.allow_identity_sign_up, cookieSecure: row.cookie_secure, cookieSamesite: row.cookie_samesite, cookieDomain: row.cookie_domain, @@ -84,6 +110,9 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader rememberMeDuration: row.remember_me_duration, enableCaptcha: row.enable_captcha, captchaSiteKey: row.captcha_site_key, + oauthStateMaxAge: row.oauth_state_max_age, + oauthRequireVerifiedEmail: row.oauth_require_verified_email, + oauthErrorRedirectPath: row.oauth_error_redirect_path, }; }, }); diff --git a/packages/express-context/src/loaders/connected-accounts-module.ts b/packages/express-context/src/loaders/connected-accounts-module.ts new file mode 100644 index 0000000000..00b71db2bb --- /dev/null +++ b/packages/express-context/src/loaders/connected-accounts-module.ts @@ -0,0 +1,51 @@ +/** + * Connected Accounts Module Loader + * + * Resolves the connected_accounts_module config from metaschema_modules_public. + * Provides schema names for querying OAuth identity associations. + */ + +import type { + ConnectedAccountsModuleConfig, + ConnectedAccountsModuleRow, +} from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const CONNECTED_ACCOUNTS_MODULE_SQL = ` + SELECT + s.schema_name, + ps.schema_name AS private_schema_name, + cam.table_name + FROM metaschema_modules_public.connected_accounts_module cam + JOIN metaschema_public.schema s ON s.id = cam.schema_id + JOIN metaschema_public.schema ps ON ps.id = cam.private_schema_id + WHERE cam.database_id = $1 + LIMIT 1 +`; + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const connectedAccountsModuleLoader: ModuleLoader = + createModuleLoader({ + name: 'connectedAccountsModule', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query( + CONNECTED_ACCOUNTS_MODULE_SQL, + [databaseId], + ); + const row = result.rows[0]; + if (!row) return undefined; + + return { + schemaName: row.schema_name, + privateSchemaName: row.private_schema_name, + tableName: row.table_name, + }; + }, + }); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts new file mode 100644 index 0000000000..b4e9f7801f --- /dev/null +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -0,0 +1,252 @@ +/** + * Identity Providers Module Loader + * + * Resolves the identity_providers_module config for the current request and + * loads enabled provider credentials from the platform database. + */ + +import { QuoteUtils } from '@pgsql/quotes'; + +import type { + InternalSecretsModuleRow, + IdentityProviderConfigMap, + IdentityProvidersConfig, + IdentityProvidersModuleRow, + PlatformDatabaseRow, + ProviderRow, + SchemaAndTableRow, +} from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const IDENTITY_PROVIDERS_MODULE_SQL = ` + SELECT + ipm.database_id, + s.schema_name, + ps.schema_name AS private_schema_name, + ipm.table_name, + ipm.scope, + ipm.prefix + FROM metaschema_modules_public.identity_providers_module ipm + JOIN metaschema_public.schema s ON s.id = ipm.schema_id + JOIN metaschema_public.schema ps ON ps.id = ipm.private_schema_id + WHERE ipm.database_id = $1 + LIMIT 1 +`; + +const INTERNAL_SECRETS_MODULE_SQL = ` + SELECT ism.internal_secrets_table_id + FROM metaschema_modules_public.internal_secrets_module ism + WHERE ism.database_id = $1 + AND ism.scope = $2 + LIMIT 1 +`; + +const SCHEMA_AND_TABLE_SQL = ` + SELECT schema_name, table_name + FROM metaschema.schema_and_table($1) +`; + +const PLATFORM_DATABASE_SQL = ` + SELECT d.id AS database_id + FROM metaschema_public.database d + WHERE d.name = $1 + OR EXISTS ( + SELECT 1 + FROM services_public.apis a + WHERE a.database_id = d.id + AND a.dbname = $1 + ) + ORDER BY + CASE WHEN d.name = $1 THEN 0 ELSE 1 END, + CASE WHEN d.owner_id IS NULL THEN 0 ELSE 1 END, + d.created_at ASC + LIMIT 1 +`; + +function buildProvidersSql( + ipSchema: string, + ipTable: string, + secretsSchema: string, + secretsTable: string, +): string { + const providersTable = QuoteUtils.quoteQualifiedIdentifier(ipSchema, ipTable); + const secretsTableName = QuoteUtils.quoteQualifiedIdentifier( + secretsSchema, + secretsTable, + ); + + return ` + SELECT + ip.slug, + ip.kind, + ip.display_name, + ip.enabled, + ip.client_id, + CASE + WHEN secrets.algo = 'pgp' THEN + convert_from(decode(pgp_sym_decrypt(secrets.value, secrets.key_id::text), 'hex'), 'SQL_ASCII') + WHEN secrets.algo = 'crypt' THEN + convert_from(secrets.value, 'SQL_ASCII') + ELSE + convert_from(secrets.value, 'UTF8') + END AS client_secret, + ip.authorization_url, + ip.token_url, + ip.userinfo_url, + ip.scopes, + ip.extra_authorization_params, + ip.pkce_enabled + FROM ${providersTable} ip + LEFT JOIN ${secretsTableName} secrets + ON secrets.id = ip.client_secret_id + WHERE ip.enabled = true + AND ip.client_id IS NOT NULL + AND ip.client_secret_id IS NOT NULL + `; +} + +function normalizeStringParams( + params: Record | null, +): Record { + if (!params) return {}; + const normalized: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string') { + normalized[key] = value; + } + } + return normalized; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +async function resolveSecretsTable( + ctx: LoaderContext, + databaseId: string, + scope: string, +): Promise { + const secretsModuleResult = + await ctx.servicesPool.query( + INTERNAL_SECRETS_MODULE_SQL, + [databaseId, scope], + ); + const secretsModuleRow = secretsModuleResult.rows[0]; + if (!secretsModuleRow) { + throw new Error( + `internal_secrets_module missing for scope ${scope} on database ${databaseId}`, + ); + } + + try { + const schemaResult = await ctx.servicesPool.query( + SCHEMA_AND_TABLE_SQL, + [secretsModuleRow.internal_secrets_table_id], + ); + const schemaRow = schemaResult.rows[0]; + if (schemaRow) return schemaRow; + } catch { + // Re-throw a module-specific error instead of leaking metaschema internals. + } + + throw new Error( + `schema/table resolution missing for internal_secrets_module scope ${scope} on database ${databaseId}`, + ); +} + +export async function resolveIdentityProvidersConfig( + ctx: LoaderContext, +): Promise { + const { servicesPool, tenantPool, databaseId, dbname } = ctx; + + const moduleResult = await tenantPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [databaseId], + ); + const moduleRow = moduleResult.rows[0]; + if (!moduleRow) { + throw new Error(`identity_providers_module missing for database ${databaseId}`); + } + const functionPrefix = moduleRow.prefix || moduleRow.scope || 'platform'; + + // Provider credentials are platform-managed; auth functions remain scoped + // to the current request database. + const platformDatabaseResult = + await servicesPool.query(PLATFORM_DATABASE_SQL, [ + dbname, + ]); + const platformDatabaseId = platformDatabaseResult.rows[0]?.database_id; + if (!platformDatabaseId) return undefined; + + const providerModuleRow = + platformDatabaseId === databaseId + ? moduleRow + : ( + await servicesPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [platformDatabaseId], + ) + ).rows[0]; + if (!providerModuleRow) { + throw new Error( + `identity_providers_module missing for database ${platformDatabaseId}`, + ); + } + + const secretsTable = await resolveSecretsTable( + ctx, + providerModuleRow.database_id, + providerModuleRow.scope, + ); + + const providersResult = await servicesPool.query( + buildProvidersSql( + providerModuleRow.private_schema_name, + providerModuleRow.table_name, + secretsTable.schema_name, + secretsTable.table_name, + ), + ); + + const providers: IdentityProviderConfigMap = new Map(); + for (const row of providersResult.rows) { + if (!row.client_id || !row.client_secret) { + continue; + } + providers.set(row.slug, { + slug: row.slug, + kind: row.kind, + displayName: row.display_name, + enabled: row.enabled, + clientId: row.client_id, + clientSecret: row.client_secret, + authorizationUrl: row.authorization_url, + tokenUrl: row.token_url, + userinfoUrl: row.userinfo_url, + scopes: row.scopes, + authorizationParams: normalizeStringParams(row.extra_authorization_params), + pkceEnabled: row.pkce_enabled ?? true, + }); + } + + return { + schemaName: moduleRow.schema_name, + privateSchemaName: moduleRow.private_schema_name, + tableName: moduleRow.table_name, + scope: moduleRow.scope, + prefix: functionPrefix, + rotateSecretFunction: `rotate_identity_provider_${functionPrefix}_secret`, + providers, + }; +} + +export const identityProvidersLoader: ModuleLoader = + createModuleLoader({ + name: 'identityProviders', + ttlMs: 5 * 60_000, + resolve: resolveIdentityProvidersConfig, + }); + +export { buildProvidersSql }; diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 794d5d1979..6bce7cfa99 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,6 +12,9 @@ * - pubkeyChallengeSettings (services_public.pubkey_settings) * - webauthnSettings(services_public.webauthn_settings) * - authSettings (metaschema_modules_public.sessions_module → tenant DB) + * - userAuthModule (metaschema_modules_public.user_auth_module) + * - identityProviders (metaschema_modules_public.identity_providers_module + providers Map) + * - connectedAccountsModule (metaschema_modules_public.connected_accounts_module) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -47,6 +50,9 @@ export { authSettingsLoader } from './auth-settings'; export { billingLoader } from './billing'; export { inferenceLogLoader } from './inference-log'; export { agentChatLoader } from './agent-chat'; +export { userAuthModuleLoader } from './user-auth-module'; +export { identityProvidersLoader } from './identity-providers'; +export { connectedAccountsModuleLoader } from './connected-accounts-module'; export { llmLoader } from './llm'; export { computeLoader } from './compute'; @@ -63,6 +69,9 @@ import { authSettingsLoader } from './auth-settings'; import { billingLoader } from './billing'; import { inferenceLogLoader } from './inference-log'; import { agentChatLoader } from './agent-chat'; +import { userAuthModuleLoader } from './user-auth-module'; +import { identityProvidersLoader } from './identity-providers'; +import { connectedAccountsModuleLoader } from './connected-accounts-module'; import { llmLoader } from './llm'; import { computeLoader } from './compute'; @@ -77,6 +86,9 @@ export function createDefaultRegistry() { registry.register(billingLoader); registry.register(inferenceLogLoader); registry.register(agentChatLoader); + registry.register(userAuthModuleLoader); + registry.register(identityProvidersLoader); + registry.register(connectedAccountsModuleLoader); registry.register(llmLoader); registry.register(computeLoader); return registry; diff --git a/packages/express-context/src/loaders/user-auth-module.ts b/packages/express-context/src/loaders/user-auth-module.ts new file mode 100644 index 0000000000..18d284ef19 --- /dev/null +++ b/packages/express-context/src/loaders/user-auth-module.ts @@ -0,0 +1,88 @@ +/** + * User Auth Module Loader + * + * Resolves the user_auth_module config from metaschema_modules_public. + * Provides schema name and function names for sign-in/sign-up operations + * including identity-based OAuth/SSO auth functions. + */ + +import type { UserAuthModuleConfig, UserAuthModuleRow } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const USER_AUTH_MODULE_SQL = ` + SELECT + s.schema_name, + sc_schema.schema_name AS session_credentials_schema_name, + uam.sign_in_function, + uam.sign_up_function, + uam.sign_out_function, + uam.sign_in_cross_origin_function, + uam.request_cross_origin_token_function, + uam.extend_token_expires + FROM metaschema_modules_public.user_auth_module uam + JOIN metaschema_public.schema s ON s.id = uam.schema_id + LEFT JOIN metaschema_public.table sc_table ON sc_table.id = uam.session_credentials_table_id + LEFT JOIN metaschema_public.schema sc_schema ON sc_schema.id = sc_table.schema_id + WHERE uam.database_id = $1 + LIMIT 1 +`; + +const IDENTITY_FUNCTION_SCHEMA_SQL = ` + SELECT n.nspname AS schema_name + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE p.proname = $1 + AND p.pronargs = 7 + ORDER BY CASE WHEN n.nspname = $2 THEN 0 ELSE 1 END, n.nspname + LIMIT 1 +`; + +const SIGN_IN_IDENTITY_FUNCTION = 'sign_in_identity'; +const SIGN_UP_IDENTITY_FUNCTION = 'sign_up_identity'; + +interface FunctionSchemaRow { + schema_name: string; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const userAuthModuleLoader: ModuleLoader = + createModuleLoader({ + name: 'userAuthModule', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query( + USER_AUTH_MODULE_SQL, + [databaseId], + ); + const row = result.rows[0]; + if (!row) return undefined; + + const identitySchemaResult = await tenantPool.query( + IDENTITY_FUNCTION_SCHEMA_SQL, + [SIGN_IN_IDENTITY_FUNCTION, row.schema_name], + ); + const identityFunctionSchemaName = + identitySchemaResult.rows[0]?.schema_name || row.schema_name; + + return { + schemaName: row.schema_name, + identityFunctionSchemaName, + sessionCredentialsSchemaName: + row.session_credentials_schema_name || row.schema_name, + signInFunction: row.sign_in_function, + signUpFunction: row.sign_up_function, + signInIdentityFunction: SIGN_IN_IDENTITY_FUNCTION, + signUpIdentityFunction: SIGN_UP_IDENTITY_FUNCTION, + signOutFunction: row.sign_out_function, + signInCrossOriginFunction: row.sign_in_cross_origin_function, + requestCrossOriginTokenFunction: row.request_cross_origin_token_function, + extendTokenExpires: row.extend_token_expires, + }; + }, + }); diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 39bb6254fa..859bfd2878 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -88,16 +88,31 @@ export interface RlsModule { currentUserAgent: string; } +export interface PgInterval { + years?: number; + months?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; +} + export interface AuthSettings { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; cookieSecure?: boolean; cookieSamesite?: string; cookieDomain?: string | null; cookieHttponly?: boolean; - cookieMaxAge?: string | null; + cookieMaxAge?: string | PgInterval | null; cookiePath?: string; - rememberMeDuration?: string | null; + rememberMeDuration?: string | PgInterval | null; enableCaptcha?: boolean; captchaSiteKey?: string | null; + oauthStateMaxAge?: string | PgInterval | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; } export interface ApiStructure { @@ -154,6 +169,128 @@ export interface AgentChatConfig { taskTableName: string | null; } +// ─── OAuth / Identity Types ───────────────────────────────────────────────── + +export interface UserAuthModuleConfig { + schemaName: string; + identityFunctionSchemaName: string; + sessionCredentialsSchemaName: string; + signInFunction: string; + signUpFunction: string; + signInIdentityFunction: string; + signUpIdentityFunction: string; + signOutFunction: string; + signInCrossOriginFunction: string | null; + requestCrossOriginTokenFunction: string | null; + extendTokenExpires: string; +} + +export interface UserAuthModuleRow { + schema_name: string; + session_credentials_schema_name: string | null; + sign_in_function: string; + sign_up_function: string; + sign_out_function: string; + sign_in_cross_origin_function: string | null; + request_cross_origin_token_function: string | null; + extend_token_expires: string; +} + +export interface AuthSettingsRow { + allow_identity_sign_in: boolean; + allow_identity_sign_up: boolean; + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | PgInterval | null; + cookie_path: string; + remember_me_duration: string | PgInterval | null; + enable_captcha: boolean; + captcha_site_key: string | null; + oauth_state_max_age: string | PgInterval | null; + oauth_require_verified_email: boolean; + oauth_error_redirect_path: string | null; +} + +export interface IdentityProviderFullConfig { + slug: string; + kind: 'oauth2' | 'oidc'; + displayName: string; + enabled: boolean; + clientId: string; + clientSecret: string; + authorizationUrl: string | null; + tokenUrl: string | null; + userinfoUrl: string | null; + scopes: string[] | null; + authorizationParams: Record; + pkceEnabled: boolean; +} + +export type IdentityProviderConfigMap = Map; + +export interface IdentityProvidersConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; + scope: string; + prefix: string; + rotateSecretFunction: string; + providers: IdentityProviderConfigMap; +} + +export interface IdentityProvidersModuleRow { + database_id: string; + schema_name: string; + private_schema_name: string; + table_name: string; + scope: string; + prefix: string | null; +} + +export interface InternalSecretsModuleRow { + internal_secrets_table_id: string; +} + +export interface SchemaAndTableRow { + schema_name: string; + table_name: string; +} + +export interface PlatformDatabaseRow { + database_id: string; +} + +export interface ProviderRow { + slug: string; + kind: 'oauth2' | 'oidc'; + display_name: string; + enabled: boolean; + client_id: string; + client_secret: string | null; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + scopes: string[] | null; + extra_authorization_params: Record | null; + pkce_enabled: boolean | null; +} + +export interface ConnectedAccountsModuleConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; +} + +export interface ConnectedAccountsModuleRow { + schema_name: string; + private_schema_name: string; + table_name: string; +} + +// ─── Compute Types ──────────────────────────────────────────────────────────── + /** One provisioned function-module scope's compute table names. */ export interface ComputeModuleConfig { schemaName: string; @@ -212,6 +349,9 @@ export interface BuiltinModuleMap { billing: BillingConfig; inferenceLog: InferenceLogConfig; agentChat: AgentChatConfig; + userAuthModule: UserAuthModuleConfig; + identityProviders: IdentityProvidersConfig; + connectedAccountsModule: ConnectedAccountsModuleConfig; llm: LlmConfig; compute: ComputeConfig; } @@ -224,6 +364,8 @@ export interface BuiltinModuleMap { */ export type WithPgClient = ( fn: (client: PoolClient) => Promise, + /** Per-call settings merged over the request pgSettings before SET LOCAL. */ + pgSettingsOverrides?: Record, ) => Promise; /** diff --git a/packages/node-type-registry/src/module-presets/auth-email-magic.ts b/packages/node-type-registry/src/module-presets/auth-email-magic.ts index 80883435f6..d6d8058ede 100644 --- a/packages/node-type-registry/src/module-presets/auth-email-magic.ts +++ b/packages/node-type-registry/src/module-presets/auth-email-magic.ts @@ -43,7 +43,7 @@ export const PresetAuthEmailMagic: ModulePreset = { 'sessions_module', 'user_state_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'emails_module', 'rls_module', 'user_auth_module', diff --git a/packages/node-type-registry/src/module-presets/auth-email.ts b/packages/node-type-registry/src/module-presets/auth-email.ts index 8ead247d29..5e69c68a9a 100644 --- a/packages/node-type-registry/src/module-presets/auth-email.ts +++ b/packages/node-type-registry/src/module-presets/auth-email.ts @@ -54,7 +54,7 @@ export const PresetAuthEmail: ModulePreset = { 'sessions_module', 'user_state_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'emails_module', 'rls_module', 'user_auth_module' diff --git a/packages/node-type-registry/src/module-presets/auth-hardened.ts b/packages/node-type-registry/src/module-presets/auth-hardened.ts index b55151a678..3c62da0bdf 100644 --- a/packages/node-type-registry/src/module-presets/auth-hardened.ts +++ b/packages/node-type-registry/src/module-presets/auth-hardened.ts @@ -40,7 +40,7 @@ export const PresetAuthHardened: ModulePreset = { 'sessions_module', 'user_state_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'emails_module', 'rls_module', 'user_auth_module', diff --git a/packages/node-type-registry/src/module-presets/auth-passkey.ts b/packages/node-type-registry/src/module-presets/auth-passkey.ts index 3aab2f3855..3215cf65c7 100644 --- a/packages/node-type-registry/src/module-presets/auth-passkey.ts +++ b/packages/node-type-registry/src/module-presets/auth-passkey.ts @@ -41,7 +41,7 @@ export const PresetAuthPasskey: ModulePreset = { 'sessions_module', 'user_state_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'emails_module', 'rls_module', 'user_auth_module', diff --git a/packages/node-type-registry/src/module-presets/auth-sso.ts b/packages/node-type-registry/src/module-presets/auth-sso.ts index b459207958..35c703aaf6 100644 --- a/packages/node-type-registry/src/module-presets/auth-sso.ts +++ b/packages/node-type-registry/src/module-presets/auth-sso.ts @@ -7,7 +7,7 @@ import type { ModulePreset } from './types'; * `(provider, external_id)`) and `identity_providers_module` (the provider * config: URLs, client_id, encrypted client_secret, scopes, PKCE/nonce * knobs). The generator then emits `sign_in_identity` / `sign_up_identity` - * procedures which rely on `config_secrets_module` to decrypt the client + * procedures which rely on `internal_secrets_module` to decrypt the client * secret at auth time. * * Password fallback stays on by default (break-glass for admins); flip the @@ -29,7 +29,7 @@ export const PresetAuthSso: ModulePreset = { 'encrypted client secrets) and `connected_accounts_module` (the junction mapping a ' + 'Constructive user to a `(provider, external_id)` pair). The generator emits ' + '`sign_in_identity` and `sign_up_identity` procedures which decrypt the client secret ' + - 'through `config_secrets_module` at auth time. Keep password flows as break-glass, or ' + + 'through `internal_secrets_module` at auth time. Keep password flows as break-glass, or ' + 'disable them via `app_settings_auth` toggles for strictly-SSO deployments.', good_for: [ 'B2B apps where end users sign in via their employer IdP', @@ -50,7 +50,7 @@ export const PresetAuthSso: ModulePreset = { 'sessions_module', 'user_state_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'emails_module', 'rls_module', 'user_auth_module', diff --git a/packages/node-type-registry/src/module-presets/b2b-storage.ts b/packages/node-type-registry/src/module-presets/b2b-storage.ts index c23de67eb9..64dc47165c 100644 --- a/packages/node-type-registry/src/module-presets/b2b-storage.ts +++ b/packages/node-type-registry/src/module-presets/b2b-storage.ts @@ -46,7 +46,7 @@ export const PresetB2bStorage: ModulePreset = { 'sessions_module', 'user_state_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'emails_module', 'rls_module', 'user_auth_module', diff --git a/packages/node-type-registry/src/module-presets/b2b.ts b/packages/node-type-registry/src/module-presets/b2b.ts index d26da7fc82..d45f4ed774 100644 --- a/packages/node-type-registry/src/module-presets/b2b.ts +++ b/packages/node-type-registry/src/module-presets/b2b.ts @@ -42,7 +42,7 @@ export const PresetB2b: ModulePreset = { 'sessions_module', 'user_state_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'emails_module', 'rls_module', 'user_auth_module', diff --git a/packages/node-type-registry/src/module-presets/full.ts b/packages/node-type-registry/src/module-presets/full.ts index 92ce31ae44..0fc49f8c42 100644 --- a/packages/node-type-registry/src/module-presets/full.ts +++ b/packages/node-type-registry/src/module-presets/full.ts @@ -63,7 +63,7 @@ export const PresetFull: ModulePreset = { 'rate_limits_module', 'devices_module', 'user_credentials_module', - 'config_secrets_module', + 'internal_secrets_module', 'rls_module', // Contact modules 'emails_module', diff --git a/packages/oauth/README.md b/packages/oauth/README.md index f8c6866041..0228ce9b56 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -54,42 +54,6 @@ const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); 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', -}); - -// Mount routes -app.get('/auth/:provider', oauth.initiateAuth); -app.get('/auth/:provider/callback', oauth.handleCallback); -app.get('/auth/providers', oauth.getProviders); -``` - ## Supported Providers | Provider | Scopes | @@ -105,10 +69,6 @@ app.get('/auth/providers', oauth.getProviders); Creates an OAuth client instance. -### `createOAuthMiddleware(config)` - -Creates Express route handlers for OAuth flows. - ### `OAuthProfile` The normalized user profile returned after authentication: @@ -120,6 +80,7 @@ interface OAuthProfile { email: string | null; name: string | null; picture: string | null; + emailVerified: boolean | null; // Whether the provider verified the email raw: unknown; // Original provider response } ``` diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts index 129701d874..e0ec4c23e8 100644 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ b/packages/oauth/__tests__/oauth-client.test.ts @@ -1,6 +1,25 @@ import { OAuthClient, createOAuthClient } from '../src/oauth-client'; -import { getProvider, getProviderIds } from '../src/providers'; +import { resolveOAuthProvider } from '../src/provider-resolver'; +import { GITHUB_EMAILS_URL, getProvider, getProviderIds } from '../src/providers'; import { generateState, verifyState } from '../src/utils/state'; +import { createSignedState, verifySignedState } from '../src/utils/signed-state'; +import { deriveCodeChallenge } from '../src/utils/pkce'; + +const originalFetch = global.fetch; + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: jest.fn().mockResolvedValue(body), + text: jest.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)), + }; +} + +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); describe('OAuthClient', () => { const config = { @@ -75,6 +94,67 @@ describe('OAuthClient', () => { expect(url).not.toContain('profile'); }); + it('should use runtime authorization URL, scopes, and extra params', () => { + const client = createOAuthClient({ + providers: { + github: { + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + authorizationUrl: 'https://github.enterprise.test/login/oauth/authorize', + tokenUrl: 'https://github.enterprise.test/login/oauth/access_token', + userinfoUrl: 'https://github.enterprise.test/api/user', + scopes: ['read:user'], + authorizationParams: { + prompt: 'select_account', + client_id: 'ignored-client-id', + }, + }, + }, + baseUrl: 'https://api.example.com', + }); + + const { url, state } = client.getAuthorizationUrl({ + provider: 'github', + state: 'runtime-state', + }); + const parsed = new URL(url); + + expect(`${parsed.origin}${parsed.pathname}`).toBe( + 'https://github.enterprise.test/login/oauth/authorize' + ); + expect(parsed.searchParams.get('client_id')).toBe('runtime-client-id'); + expect(parsed.searchParams.get('scope')).toBe('read:user'); + expect(parsed.searchParams.get('prompt')).toBe('select_account'); + expect(parsed.searchParams.get('state')).toBe('runtime-state'); + expect(state).toBe('runtime-state'); + }); + + it('should add a PKCE challenge when runtime config enables PKCE', () => { + const client = createOAuthClient({ + providers: { + google: { + clientId: 'runtime-google-client-id', + clientSecret: 'runtime-google-client-secret', + pkceEnabled: true, + }, + }, + baseUrl: 'https://api.example.com', + }); + + const { url, codeVerifier } = client.getAuthorizationUrl({ + provider: 'google', + state: 'pkce-state', + }); + const parsed = new URL(url); + + expect(codeVerifier).toBeDefined(); + expect(codeVerifier).toHaveLength(43); + expect(parsed.searchParams.get('code_challenge')).toBe( + deriveCodeChallenge(codeVerifier!) + ); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + }); + it('should throw error for unknown provider', () => { const client = createOAuthClient(config); @@ -92,6 +172,219 @@ describe('OAuthClient', () => { }); }); + describe('exchangeCode', () => { + it('should exchange legacy credential-only config with static provider defaults', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'legacy-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + const tokens = await client.exchangeCode({ + provider: 'google', + code: 'legacy-code', + }); + + expect(tokens.access_token).toBe('legacy-token'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + expect.objectContaining({ method: 'POST' }) + ); + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(request.headers).toMatchObject({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); + const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBe('test-google-client-id'); + expect(body.get('client_secret')).toBe('test-google-client-secret'); + expect(body.get('code')).toBe('legacy-code'); + }); + + it('should use runtime token URL, content type, and extra token params', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'runtime-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + github: { + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + authorizationUrl: 'https://github.enterprise.test/login/oauth/authorize', + tokenUrl: 'https://github.enterprise.test/login/oauth/access_token', + userinfoUrl: 'https://github.enterprise.test/api/user', + scopes: ['read:user'], + tokenRequestContentType: 'form', + tokenParams: { + audience: 'constructive-api', + client_secret: 'ignored-client-secret', + }, + }, + }, + baseUrl: 'https://api.example.com', + }); + + const tokens = await client.exchangeCode({ + provider: 'github', + code: 'runtime-code', + }); + + expect(tokens.access_token).toBe('runtime-token'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://github.enterprise.test/login/oauth/access_token', + expect.objectContaining({ method: 'POST' }) + ); + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(request.headers).toMatchObject({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); + const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBe('runtime-client-id'); + expect(body.get('client_secret')).toBe('runtime-client-secret'); + expect(body.get('audience')).toBe('constructive-api'); + expect(body.get('code')).toBe('runtime-code'); + }); + + it('should use client_secret_basic without leaking client_secret into the body', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'basic-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'basic client/id:+', + clientSecret: 'basic secret:/+@', + tokenEndpointAuthMethod: 'client_secret_basic', + }, + }, + baseUrl: 'https://api.example.com', + }); + + await client.exchangeCode({ provider: 'google', code: 'basic-code' }); + + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(request.headers).toMatchObject({ + Authorization: + 'Basic YmFzaWMrY2xpZW50JTJGaWQlM0ElMkI6YmFzaWMrc2VjcmV0JTNBJTJGJTJCJTQw', + }); + const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(body.get('code')).toBe('basic-code'); + }); + + it('should allow token endpoint auth method none without a client secret', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'public-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'public-client-id', + tokenEndpointAuthMethod: 'none', + }, + }, + baseUrl: 'https://api.example.com', + }); + + const tokens = await client.exchangeCode({ + provider: 'google', + code: 'public-code', + }); + + expect(tokens.access_token).toBe('public-token'); + const request = fetchMock.mock.calls[0][1] as RequestInit; + const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBe('public-client-id'); + expect(body.get('client_secret')).toBeNull(); + }); + + it('should reject unsupported private_key_jwt token endpoint auth', async () => { + const client = createOAuthClient({ + providers: { + google: { + clientId: 'jwt-client-id', + tokenEndpointAuthMethod: 'private_key_jwt', + }, + }, + baseUrl: 'https://api.example.com', + }); + + await expect( + client.exchangeCode({ provider: 'google', code: 'jwt-code' }) + ).rejects.toMatchObject({ + code: 'TOKEN_AUTH_UNSUPPORTED', + }); + }); + + it('should send PKCE code_verifier during token exchange', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'pkce-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'pkce-client-id', + clientSecret: 'pkce-client-secret', + pkceEnabled: true, + }, + }, + baseUrl: 'https://api.example.com', + }); + + await client.exchangeCode({ + provider: 'google', + code: 'pkce-code', + codeVerifier: 'test-code-verifier', + }); + + const request = fetchMock.mock.calls[0][1] as RequestInit; + const body = new URLSearchParams(request.body as string); + expect(body.get('code_verifier')).toBe('test-code-verifier'); + }); + + it('should require a PKCE code_verifier during token exchange when PKCE is enabled', async () => { + const client = createOAuthClient({ + providers: { + google: { + clientId: 'pkce-client-id', + clientSecret: 'pkce-client-secret', + pkceEnabled: true, + }, + }, + baseUrl: 'https://api.example.com', + }); + + await expect( + client.exchangeCode({ provider: 'google', code: 'pkce-code' }) + ).rejects.toMatchObject({ + code: 'PKCE_VERIFIER_REQUIRED', + }); + }); + }); + describe('getConfig', () => { it('should return config with defaults', () => { const client = createOAuthClient(config); @@ -116,6 +409,347 @@ describe('OAuthClient', () => { expect(returnedConfig.stateCookieMaxAge).toBe(300); }); }); + + describe('getUserProfile', () => { + it('should enrich GitHub public email verification serially', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + name: 'Octo Cat', + email: 'octo@example.com', + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'octo@example.com', + primary: true, + verified: true, + }, + ]) + ); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('octo@example.com'); + expect(profile.emailVerified).toBe(true); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.github.com/user', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer github-token', + 'User-Agent': 'Constructive-OAuth', + }), + }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + GITHUB_EMAILS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer github-token', + 'User-Agent': 'Constructive-OAuth', + }), + }) + ); + }); + + it('should preserve explicit GitHub unverified email status', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: 'octo@example.com', + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'octo@example.com', + primary: true, + verified: false, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('octo@example.com'); + expect(profile.emailVerified).toBe(false); + }); + + it('should not replace a public GitHub email when no email-list match exists', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: 'public@example.com', + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'primary@example.com', + primary: true, + verified: true, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('public@example.com'); + expect(profile.emailVerified).toBeNull(); + }); + + it('should use the best GitHub fallback email when profile email is private', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'secondary@example.com', + primary: false, + verified: true, + }, + { + email: 'primary@example.com', + primary: true, + verified: true, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('primary@example.com'); + expect(profile.emailVerified).toBe(true); + }); + + it('should use a verified GitHub fallback email when there is no primary verified email', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'first@example.com', + primary: false, + verified: false, + }, + { + email: 'verified@example.com', + primary: false, + verified: true, + }, + { + email: 'primary@example.com', + primary: true, + verified: false, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('verified@example.com'); + expect(profile.emailVerified).toBe(true); + }); + + it('should use the primary GitHub fallback email when no verified email exists', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'secondary@example.com', + primary: false, + verified: false, + }, + { + email: 'primary@example.com', + primary: true, + verified: false, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('primary@example.com'); + expect(profile.emailVerified).toBe(false); + }); + + it('should keep the base GitHub profile when email enrichment fails', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: 'octo@example.com', + }) + ) + .mockResolvedValueOnce(jsonResponse('forbidden', false, 403)); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('octo@example.com'); + expect(profile.emailVerified).toBeNull(); + }); + + it('should fail when the GitHub profile request fails', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce(jsonResponse('server error', false, 500)); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + + await expect(client.getUserProfile('github', 'github-token')).rejects.toMatchObject({ + code: 'USER_PROFILE_FAILED', + statusCode: 500, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should not call the GitHub emails endpoint for other providers', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + sub: '123456789', + email: 'test@gmail.com', + email_verified: true, + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('google', 'google-token'); + + expect(profile.email).toBe('test@gmail.com'); + expect(profile.emailVerified).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should use runtime userinfo URL and method', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + sub: 'runtime-google-id', + email: 'runtime@gmail.com', + email_verified: true, + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'runtime-google-client-id', + clientSecret: 'runtime-google-client-secret', + userinfoUrl: 'https://idp.example.test/oauth/userinfo', + userInfoMethod: 'POST', + }, + }, + baseUrl: 'https://api.example.com', + }); + const profile = await client.getUserProfile('google', 'runtime-token'); + + expect(profile.email).toBe('runtime@gmail.com'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://idp.example.test/oauth/userinfo', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-token', + }), + }) + ); + }); + + it('should derive the GitHub emails URL from runtime userinfo URL', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'enterprise@example.com', + primary: true, + verified: true, + }, + ]) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + github: { + clientId: 'runtime-github-client-id', + clientSecret: 'runtime-github-client-secret', + userinfoUrl: 'https://github.enterprise.test/api/v3/user', + }, + }, + baseUrl: 'https://api.example.com', + }); + const profile = await client.getUserProfile('github', 'runtime-github-token'); + + expect(profile.email).toBe('enterprise@example.com'); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://github.enterprise.test/api/v3/user/emails', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-github-token', + }), + }) + ); + expect(fetchMock).not.toHaveBeenCalledWith( + GITHUB_EMAILS_URL, + expect.anything() + ); + }); + }); }); describe('providers', () => { @@ -141,6 +775,125 @@ describe('providers', () => { }); }); +describe('provider resolver', () => { + it('should merge runtime config over static provider defaults', () => { + const resolved = resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oauth2', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + authorizationUrl: 'https://github.enterprise.test/login/oauth/authorize', + tokenUrl: 'https://github.enterprise.test/login/oauth/access_token', + userinfoUrl: 'https://github.enterprise.test/api/user', + scopes: ['read:user'], + pkceEnabled: true, + }, + }); + + expect(resolved.providerId).toBe('github'); + expect(resolved.provider.id).toBe('github'); + expect(resolved.config.authorizationUrl).toBe( + 'https://github.enterprise.test/login/oauth/authorize' + ); + expect(resolved.config.tokenUrl).toBe( + 'https://github.enterprise.test/login/oauth/access_token' + ); + expect(resolved.config.userinfoUrl).toBe( + 'https://github.enterprise.test/api/user' + ); + expect(resolved.config.scopes).toEqual(['read:user']); + expect(resolved.config.pkceEnabled).toBe(true); + }); + + it('should use static scopes when runtime scopes are not configured', () => { + const resolved = resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oauth2', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + scopes: null, + }, + }); + + expect(resolved.config.scopes).toEqual(['user:email', 'read:user']); + }); + + it('should allow public client config without a client secret', () => { + const resolved = resolveOAuthProvider({ + providerId: 'google', + runtimeConfig: { + slug: 'google', + kind: 'oidc', + clientId: 'public-client-id', + tokenEndpointAuthMethod: 'none', + }, + }); + + expect(resolved.config.tokenEndpointAuthMethod).toBe('none'); + expect(resolved.config.clientSecret).toBeUndefined(); + }); + + it('should require a client secret for default confidential clients', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'google', + runtimeConfig: { + slug: 'google', + kind: 'oidc', + clientId: 'confidential-client-id', + }, + }) + ).toThrow('missing required config: clientSecret'); + }); + + it('should reject disabled providers', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oauth2', + enabled: false, + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + }, + }) + ).toThrow('Provider github is disabled'); + }); + + it('should reject kind mismatches for known providers', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oidc', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + }, + }) + ).toThrow('does not match provider kind'); + }); + + it('should reject unknown providers', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'unknown', + runtimeConfig: { + slug: 'unknown', + kind: 'oauth2', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + }, + }) + ).toThrow('Unknown provider: unknown'); + }); +}); + describe('state utilities', () => { describe('generateState', () => { it('should generate random state of default length', () => { @@ -184,6 +937,77 @@ describe('state utilities', () => { expect(verifyState('short', 'much-longer-state')).toBe(false); }); }); + + describe('signed state', () => { + interface RedirectStatePayload { + redirect_uri: string; + provider: string; + } + + const payload: RedirectStatePayload = { + redirect_uri: '/dashboard', + provider: 'github', + }; + + it('should verify a signed state payload', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: 60_000, + }); + + const verified = verifySignedState(state, { + secret: 'test-secret', + }); + + expect(verified).toMatchObject(payload); + expect(verified?.nonce).toHaveLength(32); + expect(typeof verified?.exp).toBe('number'); + }); + + it('should reject a signed state with the wrong secret', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: 60_000, + }); + + expect( + verifySignedState(state, { + secret: 'other-secret', + }) + ).toBeNull(); + }); + + it('should reject an expired signed state', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: -1, + }); + + expect( + verifySignedState(state, { + secret: 'test-secret', + }) + ).toBeNull(); + }); + + it('should return null when verifying without a secret', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: 60_000, + }); + + expect(verifySignedState(state, {})).toBeNull(); + }); + + it('should require a secret when creating signed state', () => { + expect(() => + createSignedState(payload, { + secret: '', + maxAgeMs: 60_000, + }) + ).toThrow('OAuth state secret is required'); + }); + }); }); describe('provider profile mapping', () => { @@ -192,6 +1016,7 @@ describe('provider profile mapping', () => { const profile = google.mapProfile({ sub: '123456789', email: 'test@gmail.com', + email_verified: true, name: 'Test User', picture: 'https://example.com/photo.jpg', }); @@ -201,6 +1026,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@gmail.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://example.com/photo.jpg'); + expect(profile.emailVerified).toBe(true); }); it('should map GitHub profile correctly', () => { @@ -218,6 +1044,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@github.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://avatars.githubusercontent.com/u/12345'); + expect(profile.emailVerified).toBeNull(); }); it('should map Facebook profile correctly', () => { @@ -234,6 +1061,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@facebook.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://example.com/fb-photo.jpg'); + expect(profile.emailVerified).toBe(true); }); it('should map LinkedIn profile correctly', () => { @@ -241,6 +1069,7 @@ describe('provider profile mapping', () => { const profile = linkedin.mapProfile({ sub: 'linkedin-123', email: 'test@linkedin.com', + email_verified: true, name: 'Test User', picture: 'https://example.com/li-photo.jpg', }); @@ -250,6 +1079,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@linkedin.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://example.com/li-photo.jpg'); + expect(profile.emailVerified).toBe(true); }); it('should handle missing optional fields', () => { @@ -263,5 +1093,6 @@ describe('provider profile mapping', () => { expect(profile.email).toBeNull(); expect(profile.name).toBeNull(); expect(profile.picture).toBeNull(); + expect(profile.emailVerified).toBeNull(); }); }); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 4461826c81..f4245855de 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -1,16 +1,25 @@ export { OAuthProviderConfig, + OAuthProviderKind, + OAuthTokenRequestContentType, + OAuthTokenEndpointAuthMethod, + OAuthProviderRuntimeConfig, + OAuthProviderResolvedConfig, + ResolvedOAuthProvider, OAuthProfile, OAuthCredentials, + OAuthClientProviderConfig, OAuthClientConfig, TokenResponse, AuthorizationUrlParams, + AuthorizationUrlResult, CallbackParams, OAuthError, createOAuthError, } from './types'; export { OAuthClient, createOAuthClient } from './oauth-client'; +export { resolveOAuthProvider } from './provider-resolver'; export { providers, @@ -23,11 +32,11 @@ export { } from './providers'; export { - createOAuthMiddleware, - OAuthMiddlewareConfig, - OAuthCallbackContext, - OAuthErrorContext, - OAuthRouteHandlers, - generateState, - verifyState, -} from './middleware/express'; + CreateSignedStateOptions, + VerifySignedStateOptions, + SignedStatePayload, + createSignedState, + verifySignedState, +} from './utils/signed-state'; + +export { generateCodeVerifier, deriveCodeChallenge } from './utils/pkce'; diff --git a/packages/oauth/src/middleware/express.ts b/packages/oauth/src/middleware/express.ts deleted file mode 100644 index 889911e8d5..0000000000 --- a/packages/oauth/src/middleware/express.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { OAuthClient } from '../oauth-client'; -import { OAuthClientConfig, OAuthProfile, createOAuthError } from '../types'; -import { generateState, verifyState } from '../utils/state'; -import { getProviderIds } from '../providers'; - -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 index 3f53bdf292..b333c6ac96 100644 --- a/packages/oauth/src/oauth-client.ts +++ b/packages/oauth/src/oauth-client.ts @@ -1,14 +1,87 @@ import { OAuthClientConfig, - OAuthCredentials, OAuthProfile, + ResolvedOAuthProvider, TokenResponse, AuthorizationUrlParams, + AuthorizationUrlResult, CallbackParams, createOAuthError, } from './types'; -import { getProvider, GITHUB_EMAILS_URL, extractPrimaryEmail } from './providers'; +import { getProvider, selectGitHubEmail } from './providers'; +import { resolveOAuthProvider } from './provider-resolver'; import { generateState } from './utils/state'; +import { deriveCodeChallenge, generateCodeVerifier } from './utils/pkce'; + +const AUTHORIZATION_PARAM_RESERVED_KEYS = new Set([ + 'client_id', + 'redirect_uri', + 'response_type', + 'scope', + 'state', + 'nonce', + 'code_challenge', + 'code_challenge_method', +]); + +const TOKEN_PARAM_RESERVED_KEYS = new Set([ + 'client_id', + 'client_secret', + 'code', + 'redirect_uri', + 'grant_type', + 'code_verifier', +]); + +function applyAdditionalParams( + target: URLSearchParams | Record, + params: Record, + reservedKeys: Set, +): void { + for (const [key, value] of Object.entries(params)) { + if (!key || reservedKeys.has(key.toLowerCase())) continue; + if (target instanceof URLSearchParams) { + target.set(key, value); + } else { + target[key] = value; + } + } +} + +function requireClientSecret( + clientSecret: string | undefined, + providerId: string, +): string { + if (!clientSecret) { + throw createOAuthError( + `Provider ${providerId} missing required config: clientSecret`, + 'PROVIDER_CONFIG_INVALID', + providerId, + ); + } + return clientSecret; +} + +function formEncodeCredential(value: string): string { + const encoded = new URLSearchParams([['value', value]]).toString(); + return encoded.slice('value='.length); +} + +function createBasicAuthorizationHeader( + clientId: string, + clientSecret: string, +): string { + const encodedClientId = formEncodeCredential(clientId); + const encodedClientSecret = formEncodeCredential(clientSecret); + return `Basic ${Buffer.from(`${encodedClientId}:${encodedClientSecret}`).toString('base64')}`; +} + +function deriveGitHubEmailsUrl(userinfoUrl: string): string { + const url = new URL(userinfoUrl); + const pathname = url.pathname.replace(/\/$/, ''); + url.pathname = `${pathname}/emails`; + return url.toString(); +} export class OAuthClient { private config: OAuthClientConfig; @@ -22,59 +95,57 @@ export class OAuthClient { }; } - getAuthorizationUrl(params: AuthorizationUrlParams): { url: string; state: string } { + getAuthorizationUrl(params: AuthorizationUrlParams): AuthorizationUrlResult { 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 { config } = this.resolveProvider(providerId); const state = customState || generateState(); - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); - const effectiveScopes = scopes || provider.scopes; + const callbackUrl = this.getCallbackUrl(providerId, redirectUri || config.redirectUri); + const effectiveScopes = scopes || config.scopes; + const codeVerifier = config.pkceEnabled ? generateCodeVerifier() : undefined; - const url = new URL(provider.authorizationUrl); - url.searchParams.set('client_id', credentials.clientId); + const url = new URL(config.authorizationUrl); + url.searchParams.set('client_id', config.clientId); url.searchParams.set('redirect_uri', callbackUrl); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', effectiveScopes.join(' ')); url.searchParams.set('state', state); + if (codeVerifier) { + url.searchParams.set('code_challenge', deriveCodeChallenge(codeVerifier)); + url.searchParams.set('code_challenge_method', 'S256'); + } + applyAdditionalParams( + url.searchParams, + config.authorizationParams, + AUTHORIZATION_PARAM_RESERVED_KEYS, + ); - return { url: url.toString(), state }; + return { url: url.toString(), state, codeVerifier }; } async exchangeCode(params: CallbackParams): Promise { - const { provider: providerId, code, redirectUri } = params; + const { provider: providerId, code, redirectUri, codeVerifier } = params; - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); + const { config } = this.resolveProvider(providerId); + if (config.tokenEndpointAuthMethod === 'private_key_jwt') { + throw createOAuthError( + 'Token endpoint auth method private_key_jwt is not supported', + 'TOKEN_AUTH_UNSUPPORTED', + providerId, + ); } - const credentials = this.config.providers[providerId]; - if (!credentials) { + if (config.pkceEnabled && !codeVerifier) { throw createOAuthError( - `No credentials configured for provider: ${providerId}`, - 'MISSING_CREDENTIALS', - providerId + `PKCE code verifier is required for provider: ${providerId}`, + 'PKCE_VERIFIER_REQUIRED', + providerId, ); } - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); + const callbackUrl = this.getCallbackUrl(providerId, redirectUri || config.redirectUri); const body: Record = { - client_id: credentials.clientId, - client_secret: credentials.clientSecret, code, redirect_uri: callbackUrl, grant_type: 'authorization_code', @@ -84,8 +155,25 @@ export class OAuthClient { Accept: 'application/json', }; + if (config.tokenEndpointAuthMethod === 'client_secret_basic') { + headers.Authorization = createBasicAuthorizationHeader( + config.clientId, + requireClientSecret(config.clientSecret, providerId), + ); + } else { + body.client_id = config.clientId; + if (config.tokenEndpointAuthMethod === 'client_secret_post') { + body.client_secret = requireClientSecret(config.clientSecret, providerId); + } + } + + if (config.pkceEnabled && codeVerifier) { + body.code_verifier = codeVerifier; + } + applyAdditionalParams(body, config.tokenParams, TOKEN_PARAM_RESERVED_KEYS); + let requestBody: string; - if (provider.tokenRequestContentType === 'json') { + if (config.tokenRequestContentType === 'json') { headers['Content-Type'] = 'application/json'; requestBody = JSON.stringify(body); } else { @@ -93,7 +181,7 @@ export class OAuthClient { requestBody = new URLSearchParams(body).toString(); } - const response = await fetch(provider.tokenUrl, { + const response = await fetch(config.tokenUrl, { method: 'POST', headers, body: requestBody, @@ -123,10 +211,7 @@ export class OAuthClient { } async getUserProfile(providerId: string, accessToken: string): Promise { - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } + const { config, provider } = this.resolveProvider(providerId); const headers: Record = { Authorization: `Bearer ${accessToken}`, @@ -137,8 +222,8 @@ export class OAuthClient { headers['User-Agent'] = 'Constructive-OAuth'; } - const response = await fetch(provider.userInfoUrl, { - method: provider.userInfoMethod || 'GET', + const response = await fetch(config.userinfoUrl, { + method: config.userInfoMethod, headers, }); @@ -153,10 +238,10 @@ export class OAuthClient { } const data = await response.json(); - let profile = provider.mapProfile(data); + const profile = provider.mapProfile(data); - if (providerId === 'github' && !profile.email) { - profile = await this.fetchGitHubEmail(accessToken, profile); + if (providerId === 'github') { + return this.fetchGitHubEmail(accessToken, profile, config.userinfoUrl); } return profile; @@ -167,12 +252,39 @@ export class OAuthClient { return this.getUserProfile(params.provider, tokens.access_token); } + private resolveProvider(providerId: string): ResolvedOAuthProvider { + const runtimeConfig = this.config.providers[providerId]; + if (!runtimeConfig) { + if (!getProvider(providerId)) { + throw createOAuthError( + `Unknown provider: ${providerId}`, + 'UNKNOWN_PROVIDER', + providerId, + ); + } + throw createOAuthError( + `No credentials configured for provider: ${providerId}`, + 'MISSING_CREDENTIALS', + providerId, + ); + } + + return resolveOAuthProvider({ + providerId, + runtimeConfig: { + slug: providerId, + ...runtimeConfig, + }, + }); + } + private async fetchGitHubEmail( accessToken: string, - profile: OAuthProfile + profile: OAuthProfile, + userinfoUrl: string, ): Promise { try { - const response = await fetch(GITHUB_EMAILS_URL, { + const response = await fetch(deriveGitHubEmailsUrl(userinfoUrl), { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', @@ -181,14 +293,26 @@ export class OAuthClient { }); if (response.ok) { - const emails = await response.json(); - const email = extractPrimaryEmail(emails); + const data = await response.json(); + if (!Array.isArray(data)) { + return profile; + } + + const email = selectGitHubEmail(data, profile.email); if (email) { - return { ...profile, email }; + if (profile.email && email.email !== profile.email) { + return profile; + } + + return { + ...profile, + email: email.email, + emailVerified: email.verified, + }; } } } catch { - // Ignore email fetch errors, return profile without email + // Ignore email fetch errors, return profile without email verification details. } return profile; } diff --git a/packages/oauth/src/provider-resolver.ts b/packages/oauth/src/provider-resolver.ts new file mode 100644 index 0000000000..46771ff721 --- /dev/null +++ b/packages/oauth/src/provider-resolver.ts @@ -0,0 +1,109 @@ +import { getProvider } from './providers'; +import { + OAuthProviderConfig, + OAuthProviderResolvedConfig, + OAuthProviderRuntimeConfig, + ResolvedOAuthProvider, + createOAuthError, +} from './types'; +import { requireProviderConfigString } from './utils/config'; + +export function resolveOAuthProvider(ctx: { + providerId: string; + runtimeConfig: OAuthProviderRuntimeConfig; + getProviderConfig?: (providerId: string) => OAuthProviderConfig | undefined; +}): ResolvedOAuthProvider { + const provider = (ctx.getProviderConfig || getProvider)(ctx.providerId); + if (!provider) { + throw createOAuthError( + `Unknown provider: ${ctx.providerId}`, + 'UNKNOWN_PROVIDER', + ctx.providerId + ); + } + + const runtimeConfig = { + ...ctx.runtimeConfig, + slug: ctx.runtimeConfig.slug || ctx.providerId, + }; + + if (runtimeConfig.enabled === false) { + throw createOAuthError( + `Provider ${ctx.providerId} is disabled`, + 'PROVIDER_DISABLED', + ctx.providerId + ); + } + + if (runtimeConfig.kind && runtimeConfig.kind !== provider.kind) { + throw createOAuthError( + `Provider ${ctx.providerId} kind ${runtimeConfig.kind} does not match provider kind ${provider.kind}`, + 'PROVIDER_CONFIG_INVALID', + ctx.providerId + ); + } + + const tokenEndpointAuthMethod = + runtimeConfig.tokenEndpointAuthMethod ?? 'client_secret_post'; + const clientSecret = + tokenEndpointAuthMethod === 'client_secret_post' || + tokenEndpointAuthMethod === 'client_secret_basic' + ? requireProviderConfigString( + runtimeConfig.clientSecret, + 'clientSecret', + ctx.providerId + ) + : runtimeConfig.clientSecret || undefined; + + const resolvedConfig: OAuthProviderResolvedConfig = { + slug: runtimeConfig.slug, + kind: runtimeConfig.kind || provider.kind, + displayName: runtimeConfig.displayName || provider.name, + enabled: runtimeConfig.enabled ?? true, + clientId: requireProviderConfigString( + runtimeConfig.clientId, + 'clientId', + ctx.providerId + ), + clientSecret, + redirectUri: runtimeConfig.redirectUri, + authorizationUrl: requireProviderConfigString( + runtimeConfig.authorizationUrl ?? provider.authorizationUrl, + 'authorizationUrl', + ctx.providerId + ), + tokenUrl: requireProviderConfigString( + runtimeConfig.tokenUrl ?? provider.tokenUrl, + 'tokenUrl', + ctx.providerId + ), + userinfoUrl: requireProviderConfigString( + runtimeConfig.userinfoUrl ?? + runtimeConfig.userInfoUrl ?? + provider.userInfoUrl, + 'userinfoUrl', + ctx.providerId + ), + scopes: + runtimeConfig.scopes === undefined || runtimeConfig.scopes === null + ? provider.scopes + : runtimeConfig.scopes, + pkceEnabled: runtimeConfig.pkceEnabled ?? false, + tokenEndpointAuthMethod, + tokenRequestContentType: + runtimeConfig.tokenRequestContentType ?? + provider.tokenRequestContentType ?? + 'form', + userInfoMethod: + runtimeConfig.userInfoMethod ?? provider.userInfoMethod ?? 'GET', + authorizationParams: runtimeConfig.authorizationParams || {}, + tokenParams: runtimeConfig.tokenParams || {}, + options: runtimeConfig.options || {}, + }; + + return { + providerId: ctx.providerId, + config: resolvedConfig, + provider, + }; +} diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts index 56c146dd1c..d509909940 100644 --- a/packages/oauth/src/providers/facebook.ts +++ b/packages/oauth/src/providers/facebook.ts @@ -16,6 +16,7 @@ const FACEBOOK_API_VERSION = 'v18.0'; export const facebookProvider: OAuthProviderConfig = { id: 'facebook', name: 'Facebook', + kind: 'oauth2', 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`, @@ -29,6 +30,7 @@ export const facebookProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || null, picture: profile.picture?.data?.url || null, + emailVerified: profile.email ? true : null, raw: data, }; }, diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts index 04ba3a5290..491c108be9 100644 --- a/packages/oauth/src/providers/github.ts +++ b/packages/oauth/src/providers/github.ts @@ -8,7 +8,7 @@ interface GitHubProfile { avatar_url?: string; } -interface GitHubEmail { +export interface GitHubEmail { email: string; primary: boolean; verified: boolean; @@ -17,6 +17,7 @@ interface GitHubEmail { export const githubProvider: OAuthProviderConfig = { id: 'github', name: 'GitHub', + kind: 'oauth2', authorizationUrl: 'https://github.com/login/oauth/authorize', tokenUrl: 'https://github.com/login/oauth/access_token', userInfoUrl: 'https://api.github.com/user', @@ -30,6 +31,7 @@ export const githubProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || profile.login || null, picture: profile.avatar_url || null, + emailVerified: null, // GitHub requires separate /user/emails API call raw: data, }; }, @@ -37,10 +39,24 @@ export const githubProvider: OAuthProviderConfig = { export const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails'; -export function extractPrimaryEmail(emails: GitHubEmail[]): string | null { +export function selectGitHubEmail( + emails: GitHubEmail[], + preferredEmail?: string | null +): GitHubEmail | null { + if (preferredEmail) { + const preferred = emails.find((e) => e.email === preferredEmail); + if (preferred) return preferred; + } + const primary = emails.find((e) => e.primary && e.verified); - if (primary) return primary.email; + if (primary) return primary; const verified = emails.find((e) => e.verified); - if (verified) return verified.email; - return emails[0]?.email || null; + if (verified) return verified; + const primaryUnverified = emails.find((e) => e.primary); + if (primaryUnverified) return primaryUnverified; + return emails[0] || null; +} + +export function extractPrimaryEmail(emails: GitHubEmail[]): string | null { + return selectGitHubEmail(emails)?.email || null; } diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts index eaeac0a11d..58f7c17427 100644 --- a/packages/oauth/src/providers/google.ts +++ b/packages/oauth/src/providers/google.ts @@ -13,6 +13,7 @@ interface GoogleProfile { export const googleProvider: OAuthProviderConfig = { id: 'google', name: 'Google', + kind: 'oidc', authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', @@ -26,6 +27,7 @@ export const googleProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || null, picture: profile.picture || null, + emailVerified: profile.email_verified ?? null, raw: data, }; }, diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index ec0a37f564..44956eb87d 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -1,6 +1,11 @@ import { OAuthProviderConfig } from '../types'; import { googleProvider } from './google'; -import { githubProvider, GITHUB_EMAILS_URL, extractPrimaryEmail } from './github'; +import { + githubProvider, + GITHUB_EMAILS_URL, + extractPrimaryEmail, + selectGitHubEmail, +} from './github'; import { facebookProvider } from './facebook'; import { linkedinProvider } from './linkedin'; @@ -26,4 +31,5 @@ export { linkedinProvider, GITHUB_EMAILS_URL, extractPrimaryEmail, + selectGitHubEmail, }; diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts index a7658c859b..d97c69687a 100644 --- a/packages/oauth/src/providers/linkedin.ts +++ b/packages/oauth/src/providers/linkedin.ts @@ -13,6 +13,7 @@ interface LinkedInProfile { export const linkedinProvider: OAuthProviderConfig = { id: 'linkedin', name: 'LinkedIn', + kind: 'oidc', authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', userInfoUrl: 'https://api.linkedin.com/v2/userinfo', @@ -26,6 +27,7 @@ export const linkedinProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || null, picture: profile.picture || null, + emailVerified: profile.email_verified ?? null, raw: data, }; }, diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index db4ef07437..1286097fb5 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -1,21 +1,92 @@ +export type OAuthProviderKind = 'oauth2' | 'oidc'; + +export type OAuthTokenRequestContentType = 'json' | 'form'; + +export type OAuthTokenEndpointAuthMethod = + | 'client_secret_post' + | 'client_secret_basic' + | 'private_key_jwt' + | 'none'; + export interface OAuthProviderConfig { id: string; name: string; + kind: OAuthProviderKind; authorizationUrl: string; tokenUrl: string; userInfoUrl: string; scopes: string[]; - tokenRequestContentType?: 'json' | 'form'; + tokenRequestContentType?: OAuthTokenRequestContentType; userInfoMethod?: 'GET' | 'POST'; mapProfile: (data: unknown) => OAuthProfile; } +export interface OAuthProviderRuntimeConfig { + slug?: string; + kind?: OAuthProviderKind; + displayName?: string; + enabled?: boolean; + + clientId: string; + clientSecret?: string; + clientSecretRef?: string; + redirectUri?: string; + + authorizationUrl?: string | null; + tokenUrl?: string | null; + userinfoUrl?: string | null; + userInfoUrl?: string | null; + + scopes?: string[] | null; + pkceEnabled?: boolean; + tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod; + tokenRequestContentType?: OAuthTokenRequestContentType; + userInfoMethod?: 'GET' | 'POST'; + + authorizationParams?: Record; + tokenParams?: Record; + options?: Record; +} + +export interface OAuthProviderResolvedConfig { + slug: string; + kind: OAuthProviderKind; + displayName: string; + enabled: boolean; + + clientId: string; + clientSecret?: string; + redirectUri?: string; + + authorizationUrl: string; + tokenUrl: string; + userinfoUrl: string; + + scopes: string[]; + pkceEnabled: boolean; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; + tokenRequestContentType: OAuthTokenRequestContentType; + userInfoMethod: 'GET' | 'POST'; + + authorizationParams: Record; + tokenParams: Record; + options: Record; +} + +export interface ResolvedOAuthProvider { + providerId: string; + config: OAuthProviderResolvedConfig; + provider: OAuthProviderConfig; +} + export interface OAuthProfile { provider: string; providerId: string; email: string | null; name: string | null; picture: string | null; + /** Whether the email is verified by the provider. null if unknown/unsupported. */ + emailVerified: boolean | null; raw: unknown; } @@ -25,8 +96,27 @@ export interface OAuthCredentials { redirectUri?: string; } +type OAuthClientProviderConfigBase = Omit< + OAuthProviderRuntimeConfig, + 'clientSecret' | 'tokenEndpointAuthMethod' +>; + +export type OAuthClientProviderConfig = + | (OAuthClientProviderConfigBase & { + tokenEndpointAuthMethod?: 'client_secret_post' | 'client_secret_basic'; + clientSecret: string; + }) + | (OAuthClientProviderConfigBase & { + tokenEndpointAuthMethod: 'none'; + clientSecret?: string; + }) + | (OAuthClientProviderConfigBase & { + tokenEndpointAuthMethod: 'private_key_jwt'; + clientSecret?: string; + }); + export interface OAuthClientConfig { - providers: Record; + providers: Record; baseUrl: string; callbackPath?: string; stateCookieName?: string; @@ -48,10 +138,17 @@ export interface AuthorizationUrlParams { scopes?: string[]; } +export interface AuthorizationUrlResult { + url: string; + state: string; + codeVerifier?: string; +} + export interface CallbackParams { provider: string; code: string; redirectUri?: string; + codeVerifier?: string; } export interface OAuthError extends Error { diff --git a/packages/oauth/src/utils/config.ts b/packages/oauth/src/utils/config.ts new file mode 100644 index 0000000000..05083038c5 --- /dev/null +++ b/packages/oauth/src/utils/config.ts @@ -0,0 +1,16 @@ +import { createOAuthError } from '../types'; + +export function requireProviderConfigString( + value: string | null | undefined, + field: string, + providerId: string +): string { + if (!value) { + throw createOAuthError( + `Provider ${providerId} missing required config: ${field}`, + 'PROVIDER_CONFIG_INVALID', + providerId + ); + } + return value; +} diff --git a/packages/oauth/src/utils/pkce.ts b/packages/oauth/src/utils/pkce.ts new file mode 100644 index 0000000000..0f98bbfb08 --- /dev/null +++ b/packages/oauth/src/utils/pkce.ts @@ -0,0 +1,17 @@ +import { createHash, randomBytes } from 'crypto'; + +const DEFAULT_CODE_VERIFIER_BYTES = 32; + +export function generateCodeVerifier( + byteLength = DEFAULT_CODE_VERIFIER_BYTES, +): string { + return randomBytes(byteLength).toString('base64url'); +} + +export function deriveCodeChallenge(codeVerifier: string): string { + if (!codeVerifier) { + throw new Error('PKCE code verifier is required'); + } + + return createHash('sha256').update(codeVerifier).digest('base64url'); +} diff --git a/packages/oauth/src/utils/signed-state.ts b/packages/oauth/src/utils/signed-state.ts new file mode 100644 index 0000000000..a33fbf5d3f --- /dev/null +++ b/packages/oauth/src/utils/signed-state.ts @@ -0,0 +1,76 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; + +export interface CreateSignedStateOptions { + secret: string; + maxAgeMs: number; +} + +export interface VerifySignedStateOptions { + secret?: string | null; +} + +export type SignedStatePayload = TPayload & { + nonce: string; + exp: number; +}; + +function assertSecret(secret: string | null | undefined): asserts secret is string { + if (!secret) { + throw new Error('OAuth state secret is required'); + } +} + +function signPayload(json: string, secret: string): string { + return createHmac('sha256', secret).update(json).digest('base64url'); +} + +export function createSignedState( + payload: TPayload, + options: CreateSignedStateOptions, +): string { + assertSecret(options.secret); + + const data: SignedStatePayload = { + ...payload, + nonce: randomBytes(16).toString('hex'), + exp: Date.now() + options.maxAgeMs, + }; + const json = JSON.stringify(data); + const signature = signPayload(json, options.secret); + + return `${Buffer.from(json).toString('base64url')}.${signature}`; +} + +export function verifySignedState( + state: string | null | undefined, + options: VerifySignedStateOptions, +): SignedStatePayload | null { + if (!state || !options.secret) return null; + + try { + const [payloadB64, signature] = state.split('.'); + if (!payloadB64 || !signature) return null; + + const json = Buffer.from(payloadB64, 'base64url').toString(); + const expectedSignature = signPayload(json, options.secret); + const signatureBuffer = Buffer.from(signature); + const expectedSignatureBuffer = Buffer.from(expectedSignature); + + if (signatureBuffer.length !== expectedSignatureBuffer.length) { + return null; + } + + if (!timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) { + return null; + } + + const data = JSON.parse(json) as SignedStatePayload; + if (typeof data.exp !== 'number' || data.exp < Date.now()) { + return null; + } + + return data; + } catch { + return null; + } +} diff --git a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap index d9a69bb20a..bc1f3df59f 100644 --- a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap @@ -84,6 +84,9 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` "useTx": false, }, }, + "oauth": { + "stateSecret": "test-state-secret", + }, "pg": { "database": "config-db", "host": "override-host", diff --git a/pgpm/env/__tests__/merge.test.ts b/pgpm/env/__tests__/merge.test.ts index 36efdc5dac..4e611df1c0 100644 --- a/pgpm/env/__tests__/merge.test.ts +++ b/pgpm/env/__tests__/merge.test.ts @@ -155,7 +155,8 @@ describe('getEnvOptions', () => { PORT: '7777', DEPLOYMENT_FAST: 'false', JOBS_SUPPORT_ANY: 'false', - JOBS_SUPPORTED: 'alpha,beta' + JOBS_SUPPORTED: 'alpha,beta', + OAUTH_STATE_SECRET: 'test-state-secret' }; const result = getEnvOptions( diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index a10f7487f5..e822ee74a6 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -83,7 +83,10 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => SMTP_MAX_MESSAGES, SMTP_NAME, SMTP_LOGGER, - SMTP_DEBUG + SMTP_DEBUG, + + // OAuth env vars + OAUTH_STATE_SECRET } = env; return { @@ -215,6 +218,9 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(SMTP_NAME && { name: SMTP_NAME }), ...(SMTP_LOGGER && { logger: parseEnvBoolean(SMTP_LOGGER) }), ...(SMTP_DEBUG && { debug: parseEnvBoolean(SMTP_DEBUG) }), + }, + oauth: { + ...(OAUTH_STATE_SECRET && { stateSecret: OAUTH_STATE_SECRET }), } }; }; diff --git a/pgpm/types/src/pgpm.ts b/pgpm/types/src/pgpm.ts index 4d74fe7cac..3273043335 100644 --- a/pgpm/types/src/pgpm.ts +++ b/pgpm/types/src/pgpm.ts @@ -131,6 +131,14 @@ export interface CDNOptions { publicUrlPrefix?: string; } +/** + * OAuth configuration options + */ +export interface OAuthOptions { + /** Secret key for signing OAuth state and PKCE cookies (HMAC-SHA256) */ + stateSecret?: string; +} + /** * SMTP email configuration options */ @@ -279,6 +287,8 @@ export interface PgpmOptions { errorOutput?: ErrorOutputOptions; /** SMTP email configuration */ smtp?: SmtpOptions; + /** OAuth configuration */ + oauth?: OAuthOptions; /** * Pluggable migration backend. Undefined = built-in `pg` (server) path. * Set `driver.plugin` to a package (e.g. `@pgpmjs/pglite-adapter`) resolved diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f638f7d4e0..b66d63f09d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1785,6 +1785,9 @@ importers: '@constructive-io/graphql-types': specifier: workspace:^ version: link:../types/dist + '@constructive-io/oauth': + specifier: workspace:^ + version: link:../../packages/oauth/dist '@constructive-io/query-builder': specifier: workspace:^ version: link:../../postgres/query-builder/dist