diff --git a/__fixtures__/seed/scoped/test-data.sql b/__fixtures__/seed/scoped/test-data.sql index dce0a856b4..3734a375f2 100644 --- a/__fixtures__/seed/scoped/test-data.sql +++ b/__fixtures__/seed/scoped/test-data.sql @@ -20,6 +20,31 @@ SET session_replication_role TO replica; +-- Current constructive-db module metadata resolves physical tables by stable +-- table id. The lightweight CNC fixture installs the metadata tables without +-- the full metaschema runtime package, so provide the same lookup contract for +-- scoped server integration tests. +CREATE SCHEMA IF NOT EXISTS metaschema; + +DO $block$ +BEGIN + IF to_regprocedure('metaschema.schema_and_table(uuid)') IS NULL THEN + EXECUTE $function$ + CREATE FUNCTION metaschema.schema_and_table(target_table_id uuid) + RETURNS TABLE(schema_name text, table_name text) + LANGUAGE sql + STABLE + AS $$ + SELECT s.schema_name, t.name + FROM metaschema_public.table t + JOIN metaschema_public.schema s ON s.id = t.schema_id + WHERE t.id = target_table_id + $$ + $function$; + END IF; +END +$block$; + -- ===================================================== -- METASCHEMA DATA -- ===================================================== diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md new file mode 100644 index 0000000000..d95bf4019b --- /dev/null +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -0,0 +1,23 @@ +# OAuth Implementation Follow-up + +## 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. + +## 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..c718d8cc1b --- /dev/null +++ b/docs/plan/oauth/oauth-local-e2e.md @@ -0,0 +1,252 @@ +# 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 requires this only when a configured OAuth flow starts, then uses it 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`. The OAuth routes use the same scoped routing plane as every other production route; do not seed `services_public` compatibility tables or rely on a default database. + +```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; +SQL +``` + +Before starting the server, ensure the callback hostname is a real scoped +routing binding for the intended API/database. Verify it through the current +routing contract: + +```sql +SELECT * +FROM routing_public.resolve_route('auth.localhost', '/', NULL); +``` + +If Google must use bare `localhost`, create that hostname through the current +routing/domain provisioning workflow and verify it with +`routing_public.resolve_route('localhost', '/', NULL)`. Do not insert a legacy +domain row directly. + +## 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 current database's `internal_secrets_module` table for the matching scope. The GraphQL server later resolves both provider and secret metadata by the routed `database_id`; it does not infer a platform database from `dbname` or read `GITHUB_OAUTH_*` / `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' +UPDATE constructive_auth_private.identity_providers +SET enabled = true, + client_id = :'github_client_id', + authorization_url = 'https://github.com/login/oauth/authorize', + token_url = 'https://github.com/login/oauth/access_token', + userinfo_url = 'https://api.github.com/user', + scopes = ARRAY['read:user', 'user:email'], + pkce_enabled = true +WHERE slug = 'github'; + +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' +UPDATE constructive_auth_private.identity_providers +SET enabled = true, + client_id = :'google_client_id', + authorization_url = 'https://accounts.google.com/o/oauth2/v2/auth', + token_url = 'https://oauth2.googleapis.com/token', + userinfo_url = 'https://openidconnect.googleapis.com/v1/userinfo', + scopes = ARRAY['openid', 'email', 'profile'], + pkce_enabled = true +WHERE slug = 'google'; + +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 6383de2044..c061378e7a 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -80,6 +80,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-test/__tests__/oauth-scoped-routing.integration.test.ts b/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts new file mode 100644 index 0000000000..3f54ef3162 --- /dev/null +++ b/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts @@ -0,0 +1,103 @@ +/** + * OAuth route integration on the production scoped-routing server. + * + * The shared fixture deliberately does not provision OAuth modules. These + * assertions prove the route still receives an explicit database/API scope + * from routing_public and that OAUTH_STATE_SECRET is required lazily only + * after a configured provider can start a flow. + */ + +import path from 'path'; +import type supertest from 'supertest'; + +import { getConnections, seed } from '../src'; + +jest.setTimeout(30000); + +const sharedSeedRoot = path.join( + __dirname, + '..', + '..', + '..', + '__fixtures__', + 'seed' +); +const shared = (...segments: string[]) => + path.join(sharedSeedRoot, ...segments); +const pgpmWorkspace = path.join(sharedSeedRoot, '..', '..'); +const schemas = ['simple-pets-public', 'simple-pets-pets-public']; +const metaSchemas = [ + 'catalog_public', + 'routing_public', + 'apps_public', + 'metaschema_public', + 'metaschema_modules_public' +]; +const API_HOST = 'app.test.constructive.io'; + +const seedAdapters = [ + seed.pgpm(pgpmWorkspace), + seed.sqlfile([ + shared('app-schemas', 'simple-pets', 'schema.sql'), + shared('scoped', 'test-data.sql'), + shared('app-schemas', 'simple-pets', 'test-data.sql'), + path.join(__dirname, '..', 'sql', 'oauth-scoped.sql') + ]) +]; + +let request: supertest.Agent; +let teardown: (() => Promise) | undefined; + +beforeAll(async () => { + ({ request, teardown } = await getConnections( + { + schemas, + authRole: 'anonymous', + server: { + useRouting: true, + api: { + isPublic: true, + metaSchemas + } + } + }, + seedAdapters + )); +}); + +afterAll(async () => { + await teardown?.(); +}); + +describe('OAuth routes over scoped routing', () => { + it('resolves the API host and returns the database-scoped provider list', async () => { + const response = await request.get('/auth/providers').set('Host', API_HOST); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ providers: ['github'] }); + }); + + it('requires the state secret lazily only when a configured flow starts', async () => { + const response = await request.get('/auth/github').set('Host', API_HOST); + + expect(response.status).toBe(302); + const redirect = new URL(response.headers.location); + expect(redirect.searchParams.get('error')).toBe('OAUTH_INIT_FAILED'); + const setCookieHeader = response.headers['set-cookie']; + const setCookies: string[] = Array.isArray(setCookieHeader) + ? setCookieHeader + : setCookieHeader + ? [setCookieHeader] + : []; + expect(setCookies.join('\n')).not.toContain('oauth_state='); + expect(setCookies.join('\n')).not.toContain('oauth_pkce='); + }); + + it('does not fall back to a default database for an unknown host', async () => { + const response = await request + .get('/auth/providers') + .set('Host', 'unknown.test.constructive.io'); + + expect(response.status).toBe(404); + }); +}); diff --git a/graphql/server-test/sql/oauth-scoped.sql b/graphql/server-test/sql/oauth-scoped.sql new file mode 100644 index 0000000000..27668d4c29 --- /dev/null +++ b/graphql/server-test/sql/oauth-scoped.sql @@ -0,0 +1,165 @@ +-- Minimal current-contract OAuth metadata for scoped server integration tests. +-- +-- This deliberately models the constructive-db per-database ownership +-- contract: identity provider metadata and its matching internal secrets +-- module share the routed database_id and scope. It does not recreate the +-- removed services_public/default-database compatibility plane. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE "simple-pets-private".oauth_internal_secrets ( + id uuid PRIMARY KEY, + value bytea NOT NULL, + algo text NOT NULL, + key_id uuid +); + +CREATE TABLE "simple-pets-private".oauth_identity_providers ( + slug text PRIMARY KEY, + kind text NOT NULL, + display_name text NOT NULL, + enabled boolean NOT NULL, + client_id text, + client_secret_id uuid, + authorization_url text, + token_url text, + userinfo_url text, + scopes text[], + extra_authorization_params jsonb, + pkce_enabled boolean +); + +INSERT INTO metaschema_public.table (id, database_id, schema_id, name) +VALUES + ( + '0a000000-0000-4000-8000-000000000001', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dba9876-043f-48ee-399d-ddc991ad978d', + 'oauth_internal_secrets' + ), + ( + '0a000000-0000-4000-8000-000000000002', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dba9876-043f-48ee-399d-ddc991ad978d', + 'oauth_identity_providers' + ) +ON CONFLICT (id) DO NOTHING; + +SET session_replication_role TO replica; + +INSERT INTO metaschema_modules_public.internal_secrets_module ( + id, + database_id, + schema_id, + private_schema_id, + internal_secrets_table_id, + internal_secrets_table_name, + scope, + prefix +) +VALUES ( + '0a000000-0000-4000-8000-000000000003', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dba9876-043f-48ee-399d-ddc991ad978d', + '6dba9876-043f-48ee-399d-ddc991ad978d', + '0a000000-0000-4000-8000-000000000001', + 'oauth_internal_secrets', + 'platform', + 'platform' +) +ON CONFLICT (database_id, scope) DO NOTHING; + +INSERT INTO metaschema_modules_public.identity_providers_module ( + id, + database_id, + schema_id, + private_schema_id, + table_id, + table_name, + scope, + prefix +) +VALUES ( + '0a000000-0000-4000-8000-000000000004', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dbae92a-5450-401b-1ed5-d69e7754940d', + '6dba9876-043f-48ee-399d-ddc991ad978d', + '0a000000-0000-4000-8000-000000000002', + 'oauth_identity_providers', + 'platform', + 'platform' +) +ON CONFLICT (database_id, scope) DO NOTHING; + +-- /auth/providers resolves user_auth_module together with provider metadata. +-- It does not execute these auth functions, so the lightweight fixture can +-- reference the provider table metadata for the required foreign keys. +INSERT INTO metaschema_modules_public.user_auth_module ( + id, + database_id, + schema_id, + emails_table_id, + users_table_id, + secrets_table_id, + encrypted_table_id, + sessions_table_id, + session_credentials_table_id, + audits_table_id +) +VALUES ( + '0a000000-0000-4000-8000-000000000005', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dbae92a-5450-401b-1ed5-d69e7754940d', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002' +) +ON CONFLICT (id) DO NOTHING; + +SET session_replication_role TO DEFAULT; + +INSERT INTO "simple-pets-private".oauth_internal_secrets ( + id, + value, + algo, + key_id +) +VALUES ( + '0a000000-0000-4000-8000-000000000006', + convert_to('scoped-routing-test-secret', 'UTF8'), + 'raw', + NULL +); + +INSERT INTO "simple-pets-private".oauth_identity_providers ( + slug, + kind, + display_name, + enabled, + client_id, + client_secret_id, + authorization_url, + token_url, + userinfo_url, + scopes, + extra_authorization_params, + pkce_enabled +) +VALUES ( + 'github', + 'oauth2', + 'GitHub', + true, + 'scoped-routing-client', + '0a000000-0000-4000-8000-000000000006', + 'https://github.example.test/authorize', + 'https://github.example.test/token', + 'https://github.example.test/userinfo', + ARRAY['read:user'], + '{"prompt":"select_account"}'::jsonb, + true +); diff --git a/graphql/server/package.json b/graphql/server/package.json index 76d942dacf..fecdcb71a4 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -46,6 +46,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:^", @@ -54,6 +55,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__/cookie.test.ts b/graphql/server/src/middleware/__tests__/cookie.test.ts index 034d268d65..680f71c805 100644 --- a/graphql/server/src/middleware/__tests__/cookie.test.ts +++ b/graphql/server/src/middleware/__tests__/cookie.test.ts @@ -1,18 +1,19 @@ import type { Request, Response } from 'express'; + +import type { AuthSettings } from '../../types'; import { - SESSION_COOKIE_NAME, + clearDeviceTokenCookie, + clearSessionCookie, DEVICE_TOKEN_COOKIE_NAME, - getSessionCookieConfig, getDeviceTokenCookieConfig, - setSessionCookie, - clearSessionCookie, - setDeviceTokenCookie, - clearDeviceTokenCookie, - parseCookieValue, getDeviceTokenFromRequest, + getSessionCookieConfig, getSessionTokenFromRequest, + parseCookieValue, + SESSION_COOKIE_NAME, + setDeviceTokenCookie, + setSessionCookie } from '../cookie'; -import type { AuthSettings } from '../../types'; describe('cookie utilities', () => { describe('getSessionCookieConfig', () => { @@ -24,7 +25,7 @@ describe('cookie utilities', () => { domain: undefined, httpOnly: true, maxAge: 86400, - path: '/', + path: '/' }); }); @@ -35,7 +36,7 @@ describe('cookie utilities', () => { cookieDomain: '.example.com', cookieHttponly: false, cookieMaxAge: '3600', - cookiePath: '/api', + cookiePath: '/api' }; const config = getSessionCookieConfig(authSettings); expect(config).toEqual({ @@ -44,14 +45,14 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: false, maxAge: 3600, - path: '/api', + path: '/api' }); }); it('uses rememberMeDuration when rememberMe is true', () => { const authSettings: AuthSettings = { cookieMaxAge: '3600', - rememberMeDuration: '2592000', // 30 days + rememberMeDuration: '2592000' // 30 days }; const config = getSessionCookieConfig(authSettings, true); expect(config.maxAge).toBe(2592000); @@ -60,7 +61,7 @@ describe('cookie utilities', () => { it('uses cookieMaxAge when rememberMe is false', () => { const authSettings: AuthSettings = { cookieMaxAge: '3600', - rememberMeDuration: '2592000', + rememberMeDuration: '2592000' }; const config = getSessionCookieConfig(authSettings, false); expect(config.maxAge).toBe(3600); @@ -68,11 +69,31 @@ describe('cookie utilities', () => { it('falls back to cookieMaxAge when rememberMeDuration is not set', () => { const authSettings: AuthSettings = { - cookieMaxAge: '7200', + cookieMaxAge: '7200' }; const config = getSessionCookieConfig(authSettings, true); expect(config.maxAge).toBe(7200); }); + + it('converts PostgreSQL interval objects to cookie seconds', () => { + const authSettings: AuthSettings = { + cookieMaxAge: { hours: 2, milliseconds: 500 }, + rememberMeDuration: { days: 30 } + }; + + expect(getSessionCookieConfig(authSettings).maxAge).toBe(7200.5); + expect(getSessionCookieConfig(authSettings, true).maxAge).toBe( + 30 * 24 * 60 * 60 + ); + }); + + it('does not misread textual PostgreSQL intervals as raw seconds', () => { + const authSettings: AuthSettings = { + cookieMaxAge: '2 hours' + }; + + expect(getSessionCookieConfig(authSettings).maxAge).toBe(86400); + }); }); describe('getDeviceTokenCookieConfig', () => { @@ -85,7 +106,7 @@ describe('cookie utilities', () => { it('uses authSettings for other cookie options', () => { const authSettings: AuthSettings = { cookieSecure: true, - cookieDomain: '.example.com', + cookieDomain: '.example.com' }; const config = getDeviceTokenCookieConfig(authSettings); expect(config.secure).toBe(true); @@ -96,7 +117,7 @@ describe('cookie utilities', () => { describe('setSessionCookie', () => { it('sets cookie with correct options', () => { const mockRes = { - cookie: jest.fn(), + cookie: jest.fn() } as unknown as Response; const config = { @@ -105,7 +126,7 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: true, maxAge: 3600, - path: '/', + path: '/' }; setSessionCookie(mockRes, 'test-token', config); @@ -119,7 +140,7 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: true, maxAge: 3600000, // converted to milliseconds - path: '/', + path: '/' } ); }); @@ -128,7 +149,7 @@ describe('cookie utilities', () => { describe('clearSessionCookie', () => { it('clears cookie with correct options', () => { const mockRes = { - clearCookie: jest.fn(), + clearCookie: jest.fn() } as unknown as Response; const config = { @@ -137,28 +158,25 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: true, maxAge: 3600, - path: '/', + path: '/' }; clearSessionCookie(mockRes, config); - expect(mockRes.clearCookie).toHaveBeenCalledWith( - SESSION_COOKIE_NAME, - { - secure: true, - sameSite: 'lax', - domain: '.example.com', - httpOnly: true, - path: '/', - } - ); + expect(mockRes.clearCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, { + secure: true, + sameSite: 'lax', + domain: '.example.com', + httpOnly: true, + path: '/' + }); }); }); describe('setDeviceTokenCookie', () => { it('sets device token cookie', () => { const mockRes = { - cookie: jest.fn(), + cookie: jest.fn() } as unknown as Response; const config: Parameters[2] = { @@ -166,7 +184,7 @@ describe('cookie utilities', () => { sameSite: 'lax', httpOnly: true, maxAge: 7776000, - path: '/', + path: '/' }; setDeviceTokenCookie(mockRes, 'device-123', config); @@ -175,7 +193,7 @@ describe('cookie utilities', () => { DEVICE_TOKEN_COOKIE_NAME, 'device-123', expect.objectContaining({ - maxAge: 7776000000, + maxAge: 7776000000 }) ); }); @@ -184,7 +202,7 @@ describe('cookie utilities', () => { describe('clearDeviceTokenCookie', () => { it('clears device token cookie', () => { const mockRes = { - clearCookie: jest.fn(), + clearCookie: jest.fn() } as unknown as Response; const config: Parameters[1] = { @@ -192,7 +210,7 @@ describe('cookie utilities', () => { sameSite: 'lax', httpOnly: true, maxAge: 7776000, - path: '/', + path: '/' }; clearDeviceTokenCookie(mockRes, config); @@ -201,7 +219,7 @@ describe('cookie utilities', () => { DEVICE_TOKEN_COOKIE_NAME, expect.objectContaining({ httpOnly: true, - path: '/', + path: '/' }) ); }); @@ -211,8 +229,8 @@ describe('cookie utilities', () => { it('parses cookie value from header', () => { const mockReq = { headers: { - cookie: 'foo=bar; constructive_session=test-token; baz=qux', - }, + cookie: 'foo=bar; constructive_session=test-token; baz=qux' + } } as unknown as Request; const value = parseCookieValue(mockReq, 'constructive_session'); @@ -222,8 +240,8 @@ describe('cookie utilities', () => { it('returns undefined when cookie not found', () => { const mockReq = { headers: { - cookie: 'foo=bar', - }, + cookie: 'foo=bar' + } } as unknown as Request; const value = parseCookieValue(mockReq, 'constructive_session'); @@ -232,7 +250,7 @@ describe('cookie utilities', () => { it('returns undefined when no cookie header', () => { const mockReq = { - headers: {}, + headers: {} } as unknown as Request; const value = parseCookieValue(mockReq, 'constructive_session'); @@ -242,8 +260,8 @@ describe('cookie utilities', () => { it('decodes URL-encoded cookie values', () => { const mockReq = { headers: { - cookie: 'token=hello%20world', - }, + cookie: 'token=hello%20world' + } } as unknown as Request; const value = parseCookieValue(mockReq, 'token'); @@ -255,8 +273,8 @@ describe('cookie utilities', () => { it('extracts device token from cookie', () => { const mockReq = { headers: { - cookie: `${DEVICE_TOKEN_COOKIE_NAME}=device-abc123`, - }, + cookie: `${DEVICE_TOKEN_COOKIE_NAME}=device-abc123` + } } as unknown as Request; const token = getDeviceTokenFromRequest(mockReq); @@ -268,8 +286,8 @@ describe('cookie utilities', () => { it('extracts session token from cookie', () => { const mockReq = { headers: { - cookie: `${SESSION_COOKIE_NAME}=session-xyz789`, - }, + cookie: `${SESSION_COOKIE_NAME}=session-xyz789` + } } as unknown as Request; const token = getSessionTokenFromRequest(mockReq); 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..c54ec73f03 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -0,0 +1,526 @@ +import { + createSignedState, + deriveCodeChallenge, + verifySignedState +} from '@constructive-io/oauth'; +import express from 'express'; +import http from 'http'; +import type { AddressInfo } from 'net'; + +import { createOAuthRoutes } from '../oauth'; + +const OAUTH_STATE_SECRET = 'test-oauth-state-secret'; +const DATABASE_ID = '00000000-0000-4000-8000-000000000001'; +const API_ID = '00000000-0000-4000-8000-000000000002'; +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; + database_id: string; + api_id: string | null; + origin: 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 { + api: { + apiId: API_ID + }, + databaseId: DATABASE_ID, + 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, + stateSecret: string | null = OAUTH_STATE_SECRET, + contextFactory: () => ReturnType< + typeof createConstructiveContext + > = createConstructiveContext +): Promise { + const app = express(); + app.use((req, _res, next) => { + (req as any).constructive = contextFactory(); + next(); + }); + app.use( + '/auth', + createOAuthRoutes({ + oauth: { + stateSecret: stateSecret ?? undefined + } + } 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); +} + +function createStatePayload( + baseUrl: string, + provider = 'github', + overrides: Partial = {} +): OAuthStatePayload { + return { + redirect_uri: '/dashboard', + provider, + database_id: DATABASE_ID, + api_id: API_ID, + origin: baseUrl, + ...overrides + }; +} + +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', + database_id: DATABASE_ID, + api_id: API_ID, + origin: baseUrl + }); + + 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_state=')) + ).toContain('Path=/auth'); + expect( + setCookies.find((value) => value.startsWith('oauth_state=')) + ).not.toContain('Domain='); + expect( + setCookies.find((value) => value.startsWith('oauth_pkce=')) + ).toContain('HttpOnly'); + expect( + setCookies.find((value) => value.startsWith('oauth_pkce=')) + ).not.toContain('Domain='); + }); + }); + + it('rejects callback requests when the PKCE verifier is not bound to the returned state', async () => { + await withOAuthServer(async (baseUrl) => { + const stateCookie = createSignedState( + createStatePayload(baseUrl), + { 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( + createStatePayload(baseUrl), + { 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('rejects callback state bound to another database', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + createStatePayload(baseUrl, 'github', { + database_id: '00000000-0000-4000-8000-000000000099' + }), + { 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)}` + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('rejects callback state bound to another API', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + createStatePayload(baseUrl, 'github', { + api_id: '00000000-0000-4000-8000-000000000099' + }), + { 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)}` + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('rejects callback state bound to another origin', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + createStatePayload(baseUrl, 'github', { + origin: 'https://other.example.test' + }), + { 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)}` + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('only requires the state secret when an OAuth flow starts', async () => { + await withOAuthServer(async (baseUrl) => { + const providersResponse = await request(`${baseUrl}/auth/providers`); + expect(providersResponse.statusCode).toBe(200); + expect(JSON.parse(providersResponse.body)).toEqual({ + providers: ['github'] + }); + + const beginResponse = await request(`${baseUrl}/auth/github`); + expect(beginResponse.statusCode).toBe(302); + const redirect = new URL(beginResponse.headers.location!); + expect(redirect.searchParams.get('error')).toBe('OAUTH_INIT_FAILED'); + expect(getSetCookieValues(beginResponse.headers)).toHaveLength(0); + }, null); + }); + + it('reports provider metadata failures instead of silently returning no providers', async () => { + await withOAuthServer( + async (baseUrl) => { + const response = await request(`${baseUrl}/auth/providers`); + + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body)).toEqual({ + error: 'OAUTH_CONFIGURATION_ERROR' + }); + }, + OAUTH_STATE_SECRET, + () => { + const context = createConstructiveContext(); + context.useModule.mockRejectedValue( + new Error('internal secrets scope mismatch') + ); + return context; + } + ); + }); + + 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..6b95fe016e 100644 --- a/graphql/server/src/middleware/cookie.ts +++ b/graphql/server/src/middleware/cookie.ts @@ -1,5 +1,7 @@ import type { Request, Response } from 'express'; + import type { AuthSettings } from '../types'; +import { pgIntervalToSeconds } from '../utils/pg-interval'; export const SESSION_COOKIE_NAME = 'constructive_session'; export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token'; @@ -23,36 +25,36 @@ export const getSessionCookieConfig = ( rememberMe = false ): CookieConfig => { const DEFAULT_MAX_AGE = 86400; // 24 hours - let maxAge = DEFAULT_MAX_AGE; - if (rememberMe && authSettings?.rememberMeDuration) { - const parsed = parseInt(authSettings.rememberMeDuration, 10); - if (!isNaN(parsed)) maxAge = parsed; - } else if (authSettings?.cookieMaxAge) { - const parsed = parseInt(authSettings.cookieMaxAge, 10); - if (!isNaN(parsed)) maxAge = parsed; - } + const configuredMaxAge = rememberMe + ? (authSettings?.rememberMeDuration ?? authSettings?.cookieMaxAge) + : authSettings?.cookieMaxAge; + const maxAge = pgIntervalToSeconds(configuredMaxAge) ?? DEFAULT_MAX_AGE; return { secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production', - sameSite: (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', + sameSite: + (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', domain: authSettings?.cookieDomain ?? undefined, httpOnly: authSettings?.cookieHttponly ?? true, maxAge, - path: authSettings?.cookiePath ?? '/', + path: authSettings?.cookiePath ?? '/' }; }; /** * Build cookie config for device token (long-lived, 90 days). */ -export const getDeviceTokenCookieConfig = (authSettings?: AuthSettings): CookieConfig => { +export const getDeviceTokenCookieConfig = ( + authSettings?: AuthSettings +): CookieConfig => { return { secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production', - sameSite: (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', + sameSite: + (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', domain: authSettings?.cookieDomain ?? undefined, httpOnly: true, maxAge: DEVICE_TOKEN_MAX_AGE, - path: authSettings?.cookiePath ?? '/', + path: authSettings?.cookiePath ?? '/' }; }; @@ -70,20 +72,23 @@ export const setSessionCookie = ( domain: config.domain, httpOnly: config.httpOnly, maxAge: config.maxAge * 1000, // Express expects milliseconds - path: config.path, + path: config.path }); }; /** * Clear the session cookie. */ -export const clearSessionCookie = (res: Response, config: CookieConfig): void => { +export const clearSessionCookie = ( + res: Response, + config: CookieConfig +): void => { res.clearCookie(SESSION_COOKIE_NAME, { secure: config.secure, sameSite: config.sameSite, domain: config.domain, httpOnly: config.httpOnly, - path: config.path, + path: config.path }); }; @@ -101,20 +106,23 @@ export const setDeviceTokenCookie = ( domain: config.domain, httpOnly: config.httpOnly, maxAge: config.maxAge * 1000, - path: config.path, + path: config.path }); }; /** * Clear the device token cookie. */ -export const clearDeviceTokenCookie = (res: Response, config: CookieConfig): void => { +export const clearDeviceTokenCookie = ( + res: Response, + config: CookieConfig +): void => { res.clearCookie(DEVICE_TOKEN_COOKIE_NAME, { secure: config.secure, sameSite: config.sameSite, domain: config.domain, httpOnly: config.httpOnly, - path: config.path, + path: config.path }); }; @@ -122,10 +130,15 @@ export const clearDeviceTokenCookie = (res: Response, config: CookieConfig): voi * Parse a cookie value from the raw Cookie header. * Avoids pulling in cookie-parser as a dependency. */ -export const parseCookieValue = (req: Request, cookieName: string): string | undefined => { +export const parseCookieValue = ( + req: Request, + cookieName: string +): string | undefined => { const header = req.headers.cookie; if (!header) return undefined; - const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`)); + const match = header + .split(';') + .find((c) => c.trim().startsWith(`${cookieName}=`)); return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined; }; @@ -139,6 +152,8 @@ export const getDeviceTokenFromRequest = (req: Request): string | undefined => { /** * Get the session token from the request cookie. */ -export const getSessionTokenFromRequest = (req: Request): string | undefined => { +export const getSessionTokenFromRequest = ( + req: Request +): string | undefined => { return parseCookieValue(req, SESSION_COOKIE_NAME); }; diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts new file mode 100644 index 0000000000..3f715d8572 --- /dev/null +++ b/graphql/server/src/middleware/oauth.ts @@ -0,0 +1,678 @@ +/** + * 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 type { + AuthSettings, + ConnectedAccountsModuleConfig, + ConstructiveContext, + IdentityProviderFullConfig, + IdentityProvidersConfig, + UserAuthModuleConfig +} from '@constructive-io/express-context'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { + createSignedState, + OAuthClient, + OAuthProfile, + verifySignedState +} from '@constructive-io/oauth'; +import { getNodeEnv } from '@pgpmjs/env'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import { Request, Response,Router } from 'express'; + +import { pgIntervalToMilliseconds } from '../utils/pg-interval'; +import { + DEVICE_TOKEN_COOKIE_NAME, + getDeviceTokenCookieConfig, + getSessionCookieConfig, + parseCookieValue, + setDeviceTokenCookie, + setSessionCookie +} from './cookie'; + +const log = new Logger('oauth'); + +const OAUTH_STATE_COOKIE = 'oauth_state'; +const OAUTH_PKCE_COOKIE = 'oauth_pkce'; +const OAUTH_COOKIE_PATH = '/auth'; +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; + database_id: string; + api_id: string | null; + origin: 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.status(500).json({ error: 'OAUTH_CONFIGURATION_ERROR' }); + } + }); + + // 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; + if (!ctx.databaseId) { + log.error(`[oauth] Missing database scope for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'API_NOT_CONFIGURED', + provider + ); + } + const state = createSignedState( + { + redirect_uri: redirectUri, + provider, + database_id: ctx.databaseId, + api_id: ctx.api.apiId ?? null, + origin: baseUrl + }, + { + secret: requireStateSecret(opts), + maxAgeMs: stateMaxAge + } + ); + + const oauthCookieOptions = { + httpOnly: true, + secure: authSettings?.cookieSecure ?? isProduction, + maxAge: stateMaxAge, + sameSite: + (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax', + path: OAUTH_COOKIE_PATH + }; + + 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, { path: OAUTH_COOKIE_PATH }); + res.clearCookie(OAUTH_PKCE_COOKIE, { path: OAUTH_COOKIE_PATH }); + + // 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 || statePayload.origin !== baseUrl) { + log.warn(`[oauth] State request scope 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 + ); + } + + if ( + statePayload.database_id !== ctx.databaseId || + statePayload.api_id !== (ctx.api.apiId ?? null) + ) { + log.warn(`[oauth] State database/API scope mismatch for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + 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 97ffde9826..6d9b2e7e8b 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -42,6 +42,7 @@ import { debugMemory } from './middleware/observability/debug-memory'; import { localObservabilityOnly } from './middleware/observability/guard'; import { createRequestLogger } from './middleware/observability/request-logger'; import { getRoutingSchema } from './middleware/routing'; +import { createOAuthRoutes } from './middleware/oauth'; const log = new Logger('server'); @@ -199,6 +200,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 85d9b64829..8d8493cee2 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -8,6 +8,7 @@ export type { ApiStructure, AuthSettings, DatabaseSettings, + PgInterval, PubkeyChallengeSettings, RlsModule, WebauthnSettings, diff --git a/graphql/server/src/utils/__tests__/pg-interval.test.ts b/graphql/server/src/utils/__tests__/pg-interval.test.ts new file mode 100644 index 0000000000..0066967599 --- /dev/null +++ b/graphql/server/src/utils/__tests__/pg-interval.test.ts @@ -0,0 +1,27 @@ +import { pgIntervalToMilliseconds, pgIntervalToSeconds } from '../pg-interval'; + +describe('PostgreSQL interval conversion', () => { + it('converts structured intervals without mixing seconds and milliseconds', () => { + const interval = { + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + milliseconds: 500 + }; + + expect(pgIntervalToMilliseconds(interval)).toBe(93_784_500); + expect(pgIntervalToSeconds(interval)).toBe(93_784.5); + }); + + it('treats numeric strings as seconds', () => { + expect(pgIntervalToMilliseconds('60')).toBe(60_000); + expect(pgIntervalToSeconds('60.5')).toBe(60.5); + }); + + it('rejects ambiguous, empty, and non-positive values', () => { + expect(pgIntervalToMilliseconds('2 hours')).toBeNull(); + expect(pgIntervalToMilliseconds('0')).toBeNull(); + expect(pgIntervalToMilliseconds(null)).toBeNull(); + }); +}); diff --git a/graphql/server/src/utils/pg-interval.ts b/graphql/server/src/utils/pg-interval.ts new file mode 100644 index 0000000000..709ce2e708 --- /dev/null +++ b/graphql/server/src/utils/pg-interval.ts @@ -0,0 +1,49 @@ +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 normalized = interval.trim(); + if (!/^[+-]?\d+(?:\.\d+)?$/.test(normalized)) return null; + const seconds = Number(normalized); + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null; + } + + let totalMilliseconds = 0; + if (interval.years) { + totalMilliseconds += interval.years * 365 * 24 * 60 * 60 * 1000; + } + if (interval.months) { + totalMilliseconds += interval.months * 30 * 24 * 60 * 60 * 1000; + } + if (interval.days) { + totalMilliseconds += interval.days * 24 * 60 * 60 * 1000; + } + if (interval.hours) totalMilliseconds += interval.hours * 60 * 60 * 1000; + if (interval.minutes) totalMilliseconds += interval.minutes * 60 * 1000; + if (interval.seconds) totalMilliseconds += interval.seconds * 1000; + if (interval.milliseconds) { + totalMilliseconds += interval.milliseconds; + } + + return totalMilliseconds > 0 ? totalMilliseconds : null; +} + +/** + * Convert a PostgreSQL interval into seconds for cookie configuration. + */ +export function pgIntervalToSeconds( + interval: string | PgInterval | null | undefined +): number | null { + const milliseconds = pgIntervalToMilliseconds(interval); + return milliseconds === null ? null : milliseconds / 1000; +} diff --git a/packages/express-context/__tests__/context.test.ts b/packages/express-context/__tests__/context.test.ts new file mode 100644 index 0000000000..37d6a86d4d --- /dev/null +++ b/packages/express-context/__tests__/context.test.ts @@ -0,0 +1,114 @@ +import type { Request } from 'express'; +import type { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; +import { withPgClient as withPgClientFn } from 'pg-query-context'; + +import { buildContext } from '../src/context'; +import type { ApiStructure } from '../src/types'; + +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn() +})); + +jest.mock('pg-query-context', () => ({ + withPgClient: jest.fn() +})); + +const api: ApiStructure = { + apiId: '00000000-0000-4000-8000-000000000002', + databaseId: '00000000-0000-4000-8000-000000000001', + dbname: 'tenant_db', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: ['app_public'] +}; + +describe('buildContext withPgClient overrides', () => { + it('merges trusted per-call settings over request settings', async () => { + const tenantPool = { name: 'tenant' } as unknown as Pool; + const routingPool = { name: 'routing' } as unknown as Pool; + const getPoolMock = getPgPool as jest.MockedFunction; + getPoolMock + .mockReturnValueOnce(tenantPool) + .mockReturnValueOnce(routingPool); + + const client = { query: jest.fn() }; + const withClientMock = withPgClientFn as jest.MockedFunction< + typeof withPgClientFn + >; + withClientMock.mockImplementation(async (_pool, _settings, callback) => + callback(client as never) + ); + + const req = { + api, + requestId: 'request-id', + clientIp: '127.0.0.1' + } as unknown as Request; + const registry = { + resolve: jest.fn() + }; + const ctx = buildContext(req, { + loaders: registry as never, + routingSchema: 'routing_public' + }); + + expect(ctx).not.toBeNull(); + const baseSettings = { ...ctx!.pgSettings }; + await ctx!.withPgClient( + async (pgClient) => { + expect(pgClient).toBe(client); + return 'ok'; + }, + { + role: 'oauth_runtime', + 'jwt.claims.origin': 'https://api.example.test' + } + ); + + expect(withClientMock).toHaveBeenCalledWith( + tenantPool, + { + ...baseSettings, + role: 'oauth_runtime', + 'jwt.claims.origin': 'https://api.example.test' + }, + expect.any(Function) + ); + expect(ctx!.pgSettings).toEqual(baseSettings); + }); + + it('propagates callback errors without mutating request settings', async () => { + const tenantPool = { name: 'tenant' } as unknown as Pool; + const routingPool = { name: 'routing' } as unknown as Pool; + const getPoolMock = getPgPool as jest.MockedFunction; + getPoolMock + .mockReturnValueOnce(tenantPool) + .mockReturnValueOnce(routingPool); + + const expectedError = new Error('transaction failed'); + const withClientMock = withPgClientFn as jest.MockedFunction< + typeof withPgClientFn + >; + withClientMock.mockRejectedValueOnce(expectedError); + + const req = { + api, + requestId: 'request-id', + clientIp: '127.0.0.1' + } as unknown as Request; + const ctx = buildContext(req, { + loaders: { resolve: jest.fn() } as never, + routingSchema: 'routing_public' + }); + + expect(ctx).not.toBeNull(); + const baseSettings = { ...ctx!.pgSettings }; + await expect( + ctx!.withPgClient(async () => 'unreachable', { + 'jwt.claims.origin': 'https://api.example.test' + }) + ).rejects.toBe(expectedError); + expect(ctx!.pgSettings).toEqual(baseSettings); + }); +}); diff --git a/packages/express-context/package.json b/packages/express-context/package.json index 51972cef39..d13a219194 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 82d87de9d3..ac59cb4b99 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -99,8 +99,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 ef35b2c415..c712631b9e 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -46,13 +46,19 @@ export type { BuiltinModuleMap, ComputeConfig, ComputeModuleConfig, + ConnectedAccountsModuleConfig, ConstructiveAPIToken, ConstructiveContext, DatabaseSettings, + IdentityProviderConfigMap, + IdentityProviderFullConfig, + IdentityProvidersConfig, InferenceLogConfig, LlmConfig, + PgInterval, PubkeyChallengeSettings, RlsModule, + UserAuthModuleConfig, WebauthnSettings, WithPgClient, } from './types'; @@ -87,14 +93,17 @@ export { authSettingsLoader, billingLoader, computeLoader, + connectedAccountsModuleLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, + identityProvidersLoader, inferenceLogLoader, pubkeyLoader, rlsLoader, + userAuthModuleLoader, llmLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/__tests__/auth-settings.test.ts b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts new file mode 100644 index 0000000000..5e066a4972 --- /dev/null +++ b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts @@ -0,0 +1,112 @@ +import type { Pool } from 'pg'; + +import { authSettingsLoader } from '../auth-settings'; +import type { LoaderContext } from '../types'; + +function createContext( + query: jest.Mock, + databaseId = 'hub-database-id' +): LoaderContext { + const tenantPool = { query } as unknown as Pool; + + return { + routingPool: { query: jest.fn() } as unknown as Pool, + tenantPool, + databaseId, + dbname: 'constructive' + }; +} + +describe('authSettingsLoader', () => { + afterEach(() => { + authSettingsLoader.invalidate(); + }); + + it('discovers auth settings through the stable table id contract', async () => { + const query = jest + .fn() + .mockResolvedValueOnce({ + rows: [ + { + schema_name: 'constructive_auth_private', + table_name: 'app_settings_auth' + } + ] + }) + .mockResolvedValueOnce({ + rows: [ + { + allow_identity_sign_in: true, + allow_identity_sign_up: true, + cookie_secure: false, + cookie_samesite: 'lax', + cookie_domain: null, + cookie_httponly: true, + cookie_max_age: { days: 30 }, + cookie_path: '/', + remember_me_duration: { days: 30 }, + enable_captcha: false, + captcha_site_key: null, + oauth_state_max_age: { minutes: 10 }, + oauth_require_verified_email: true, + oauth_error_redirect_path: '/auth/error' + } + ] + }); + + const config = await authSettingsLoader.resolve(createContext(query)); + + expect(query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('sm.auth_settings_table_id'), + ['hub-database-id'] + ); + expect(query.mock.calls[0][0]).toContain('metaschema_public.table'); + expect(query.mock.calls[0][0]).toContain('metaschema_public.schema'); + expect(query.mock.calls[0][0]).toContain( + 't.database_id = sm.database_id' + ); + expect(query.mock.calls[0][0]).toContain( + 's.database_id = sm.database_id' + ); + expect(query.mock.calls[0][0]).not.toContain('sm.auth_settings_table_name'); + expect(query.mock.calls[0][0]).not.toContain('sm.auth_settings_table AS'); + expect(query.mock.calls[1][0]).toContain( + 'constructive_auth_private.app_settings_auth' + ); + expect(config).toMatchObject({ + allowIdentitySignIn: true, + allowIdentitySignUp: true, + cookieSecure: false, + cookieSamesite: 'lax', + oauthRequireVerifiedEmail: true, + oauthErrorRedirectPath: '/auth/error' + }); + }); + + it('returns undefined when the sessions module is not provisioned', async () => { + const query = jest.fn().mockResolvedValueOnce({ rows: [] }); + + await expect( + authSettingsLoader.resolve(createContext(query, 'unprovisioned-db')) + ).resolves.toBeUndefined(); + expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledWith( + expect.stringContaining('sm.database_id = $1'), + ['unprovisioned-db'] + ); + }); + + it('fails clearly when resolved table metadata is incomplete', async () => { + const query = jest.fn().mockResolvedValueOnce({ + rows: [{ schema_name: null, table_name: 'app_settings_auth' }] + }); + + await expect( + authSettingsLoader.resolve(createContext(query, 'invalid-metadata-db')) + ).rejects.toThrow( + 'invalid auth settings table metadata for database invalid-metadata-db' + ); + expect(query).toHaveBeenCalledTimes(1); + }); +}); 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..819ae5396c --- /dev/null +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -0,0 +1,267 @@ +import type { Pool } from 'pg'; + +import { + buildProvidersSql, + identityProvidersLoader, + resolveIdentityProvidersConfig +} from '../identity-providers'; +import type { LoaderContext } from '../types'; +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 } as unknown as Pool, + queries + }; +} + +function createContext( + tenantPool: Pool, + databaseId = 'tenant-db', + routingPool: Pool = { query: jest.fn() } as unknown as Pool +): LoaderContext { + return { + routingPool, + tenantPool, + databaseId, + apiId: 'api-id', + dbname: 'constructive-test' + }; +} + +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', () => { + afterEach(() => { + identityProvidersLoader.invalidate(); + }); + + it('loads database-owned provider config through internal secrets metadata', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + () => ({ + rows: [{ internal_secrets_table_id: 'secrets-table-id' }] + }), + () => ({ + rows: [ + { + schema_name: 'secret_private', + table_name: 'app_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 routing = createMockPool([]); + + const config = await resolveIdentityProvidersConfig( + createContext(tenant.pool, 'tenant-db', routing.pool) + ); + + expect(config).toMatchObject({ + schemaName: 'auth_public', + privateSchemaName: 'auth_private', + tableName: 'identity_providers', + scope: 'app', + prefix: 'app', + rotateSecretFunction: 'rotate_identity_provider_app_secret' + }); + expect(config?.providers.get('github')).toMatchObject({ + clientId: 'dummy-client-id', + clientSecret: 'dummy-client-secret', + authorizationUrl: 'https://github.example/authorize', + authorizationParams: { prompt: 'select_account' } + }); + expect(routing.queries).toHaveLength(0); + expect(tenant.queries[0].values).toEqual(['tenant-db']); + expect(tenant.queries[1].sql).toContain('internal_secrets_module'); + expect(tenant.queries[1].values).toEqual(['tenant-db', 'app']); + expect(tenant.queries[2].sql).toContain('metaschema.schema_and_table'); + expect(tenant.queries[3].sql).toContain('secret_private.app_secrets'); + expect(tenant.queries.map(({ sql }) => sql).join('\n')).not.toContain( + 'services_public' + ); + expect(tenant.queries.map(({ sql }) => sql).join('\n')).not.toContain( + 'config_secrets_module' + ); + }); + + it('returns undefined when identity providers are not provisioned', async () => { + const tenant = createMockPool([() => ({ rows: [] })]); + + await expect( + resolveIdentityProvidersConfig(createContext(tenant.pool)) + ).resolves.toBeUndefined(); + expect(tenant.queries).toHaveLength(1); + }); + + it('rejects ambiguous provider ownership within one database', async () => { + const tenant = createMockPool([ + () => ({ + rows: [ + identityProvidersModuleRow('tenant-db', 'app'), + identityProvidersModuleRow('tenant-db', 'platform') + ] + }) + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(tenant.pool)) + ).rejects.toThrow( + 'multiple identity_providers_module rows found for database tenant-db' + ); + expect(tenant.queries).toHaveLength(1); + }); + + it('throws when the matching internal secrets scope is missing', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + () => ({ rows: [] }) + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(tenant.pool)) + ).rejects.toThrow( + 'internal_secrets_module missing for scope app on database tenant-db' + ); + }); + + it('throws when the internal secrets table id cannot be resolved', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + () => ({ + rows: [{ internal_secrets_table_id: 'missing-table-id' }] + }), + () => { + throw new Error('NOT_FOUND'); + } + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(tenant.pool)) + ).rejects.toThrow( + 'schema/table resolution missing for internal_secrets_module scope app on database tenant-db' + ); + }); + + it('builds provider SQL from resolved identifiers', () => { + const sql = buildProvidersSql( + 'auth_private', + 'identity_providers', + 'secret_private', + 'app_secrets' + ); + + expect(sql).toContain('auth_private.identity_providers'); + expect(sql).toContain('secret_private.app_secrets'); + expect(sql).not.toContain('constructive_store_private'); + expect(sql).not.toContain('platform_secrets'); + }); +}); + +describe('userAuthModuleLoader', () => { + afterEach(() => { + userAuthModuleLoader.invalidate(); + }); + + it('resolves 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 config = await userAuthModuleLoader.resolve( + createContext(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 functions are 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 config = await userAuthModuleLoader.resolve( + createContext(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 2b0a7c505d..de087d2683 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -10,80 +10,122 @@ * database rather than the routing database. */ -import type { AuthSettings } from '../types'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { Pool } from 'pg'; + +import type { AuthSettings, AuthSettingsRow } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── const AUTH_SETTINGS_DISCOVERY_SQL = ` - SELECT s.schema_name, sm.auth_settings_table_name AS table_name + SELECT s.schema_name, t.name AS table_name FROM metaschema_modules_public.sessions_module sm - JOIN metaschema_public.schema s ON s.id = sm.schema_id + JOIN metaschema_public.table t + ON t.id = sm.auth_settings_table_id + AND t.database_id = sm.database_id + JOIN metaschema_public.schema s + ON s.id = t.schema_id + AND s.database_id = sm.database_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; +} -// ─── Row Types ────────────────────────────────────────────────────────────── +async function discoverAuthSettingsTable( + pool: Pool, + databaseId: string | null | undefined +): Promise { + if (!databaseId) return null; + + 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; + if ( + typeof resolved.schema_name !== 'string' || + resolved.schema_name.length === 0 || + typeof resolved.table_name !== 'string' || + resolved.table_name.length === 0 + ) { + throw new Error( + `invalid auth settings table metadata for database ${databaseId}` + ); + } -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 ───────────────────────────────────────────────────────────────── -export const authSettingsLoader: ModuleLoader = createModuleLoader({ - name: 'authSettings', - ttlMs: 5 * 60_000, - async resolve(ctx: LoaderContext) { - const { tenantPool } = ctx; +export const authSettingsLoader: ModuleLoader = + createModuleLoader({ + name: 'authSettings', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + 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]; - if (!resolved) return undefined; + 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) - ); - const row = result.rows[0]; - if (!row) return undefined; + const result = await tenantPool.query( + buildAuthSettingsQuery(resolved.schemaName, resolved.tableName) + ); + const row = result.rows[0]; + if (!row) return undefined; - return { - cookieSecure: row.cookie_secure, - cookieSamesite: row.cookie_samesite, - cookieDomain: row.cookie_domain, - cookieHttponly: row.cookie_httponly, - cookieMaxAge: row.cookie_max_age, - cookiePath: row.cookie_path, - rememberMeDuration: row.remember_me_duration, - enableCaptcha: row.enable_captcha, - captchaSiteKey: row.captcha_site_key - }; - } -}); + 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, + cookieHttponly: row.cookie_httponly, + cookieMaxAge: row.cookie_max_age, + cookiePath: row.cookie_path, + 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..bb00d80243 --- /dev/null +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -0,0 +1,216 @@ +/** + * Identity Providers Module Loader + * + * Resolves the identity_providers_module config and enabled provider + * credentials for the current request database. + */ + +import { QuoteUtils } from '@pgsql/quotes'; + +import type { + IdentityProviderConfigMap, + IdentityProvidersConfig, + IdentityProvidersModuleRow, + InternalSecretsModuleRow, + ProviderRow, + SchemaAndTableRow +} from '../types'; +import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; + +// ─── 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 +`; + +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) +`; + +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.tenantPool.query( + INTERNAL_SECRETS_MODULE_SQL, + [databaseId, scope] + ); + const tableId = secretsModuleResult.rows[0]?.internal_secrets_table_id; + if (!tableId) { + throw new Error( + `internal_secrets_module missing for scope ${scope} on database ${databaseId}` + ); + } + + try { + const schemaResult = await ctx.tenantPool.query( + SCHEMA_AND_TABLE_SQL, + [tableId] + ); + 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 { tenantPool, databaseId } = ctx; + + const moduleResult = await tenantPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [databaseId] + ); + if (moduleResult.rows.length > 1) { + throw new Error( + `multiple identity_providers_module rows found for database ${databaseId}; provider ownership scope must be explicit` + ); + } + const moduleRow = moduleResult.rows[0]; + if (!moduleRow) { + return undefined; + } + const functionPrefix = moduleRow.prefix || moduleRow.scope; + + const secretsTable = await resolveSecretsTable( + ctx, + databaseId, + moduleRow.scope + ); + + const providersResult = await tenantPool.query( + buildProvidersSql( + moduleRow.private_schema_name, + moduleRow.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 a1fcbb424c..a38609f5ef 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,6 +12,9 @@ * - pubkeyChallengeSettings (routing-plane pubkey_settings) * - webauthnSettings(routing-plane 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: * @@ -42,12 +45,15 @@ export { agentChatLoader } from './agent-chat'; export { authSettingsLoader } from './auth-settings'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; +export { connectedAccountsModuleLoader } from './connected-accounts-module'; export { corsLoader } from './cors'; export { databaseSettingsLoader } from './database-settings'; +export { identityProvidersLoader } from './identity-providers'; export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; export { rlsLoader } from './rls'; +export { userAuthModuleLoader } from './user-auth-module'; export { webauthnLoader } from './webauthn'; /** @@ -57,13 +63,16 @@ import { agentChatLoader } from './agent-chat'; import { authSettingsLoader } from './auth-settings'; import { billingLoader } from './billing'; import { computeLoader } from './compute'; +import { connectedAccountsModuleLoader } from './connected-accounts-module'; import { corsLoader } from './cors'; import { databaseSettingsLoader } from './database-settings'; +import { identityProvidersLoader } from './identity-providers'; import { inferenceLogLoader } from './inference-log'; import { llmLoader } from './llm'; import { pubkeyLoader } from './pubkey'; import { createLoaderRegistry } from './registry'; import { rlsLoader } from './rls'; +import { userAuthModuleLoader } from './user-auth-module'; import { webauthnLoader } from './webauthn'; export function createDefaultRegistry() { @@ -79,5 +88,8 @@ export function createDefaultRegistry() { registry.register(agentChatLoader); registry.register(llmLoader); registry.register(computeLoader); + registry.register(userAuthModuleLoader); + registry.register(identityProvidersLoader); + registry.register(connectedAccountsModuleLoader); 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 ab77655d8b..af7549b1fa 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -66,16 +66,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 { @@ -169,6 +184,122 @@ export interface LlmConfig { ragContextLimit: number | 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 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; +} + // ─── Module Types Map ─────────────────────────────────────────────────────── /** @@ -191,6 +322,9 @@ export interface BuiltinModuleMap { agentChat: AgentChatConfig; llm: LlmConfig; compute: ComputeConfig; + userAuthModule: UserAuthModuleConfig; + identityProviders: IdentityProvidersConfig; + connectedAccountsModule: ConnectedAccountsModuleConfig; } // ─── Constructive Context ─────────────────────────────────────────────────── @@ -201,6 +335,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; /** @@ -244,7 +380,9 @@ export interface ConstructiveContext { * - The module isn't provisioned for this database */ useModule: { - (name: K): Promise; + ( + name: K + ): Promise; (name: string): Promise; }; 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 46f89bc304..5da2e0078d 100644 --- a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap @@ -54,6 +54,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 8924d0b4ec..7b39234223 100644 --- a/pgpm/env/__tests__/merge.test.ts +++ b/pgpm/env/__tests__/merge.test.ts @@ -153,7 +153,8 @@ describe('getEnvOptions', () => { DB_CONNECTIONS_APP_PASSWORD: 'env-app-pass', DB_CONNECTIONS_ADMIN_USER: 'env-admin-user', PORT: '7777', - DEPLOYMENT_FAST: 'false' + DEPLOYMENT_FAST: 'false', + OAUTH_STATE_SECRET: 'test-state-secret' }; const result = getEnvOptions( diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index f510cf03fe..6442741eaf 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -78,7 +78,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 { @@ -173,6 +176,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 ef28bd15c8..f2eaba20d3 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 */ @@ -299,6 +307,8 @@ export interface PgpmOptions { errorOutput?: ErrorOutputOptions; /** SMTP email configuration */ smtp?: SmtpOptions; + /** OAuth configuration */ + oauth?: OAuthOptions; /** * Directory (relative to the workspace root) where pgpm modules are installed * by `pgpm install`. Defaults to `extensions`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 845a00a723..13a05a61c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1962,6 +1962,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 @@ -1986,6 +1989,9 @@ importers: '@pgpmjs/types': specifier: workspace:^ version: link:../../pgpm/types/dist + '@pgsql/quotes': + specifier: ^17.1.0 + version: 17.1.0 agentic-server: specifier: workspace:* version: link:../../agentic/agentic-server/dist @@ -2523,6 +2529,9 @@ importers: '@pgpmjs/types': specifier: workspace:^ version: link:../../pgpm/types/dist + '@pgsql/quotes': + specifier: ^17.1.0 + version: 17.1.0 lru-cache: specifier: ^11.2.7 version: 11.2.7 @@ -5452,6 +5461,12 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pgsql/quotes@17.1.0': + resolution: + { + integrity: sha512-J/H+LcrENBpYgL45WW6aTjb5Yk4tX4+AmB2/k8KZa+Zh3wiCtqmNIag+HZz5HmWaF6EZK9ZGC95NBD1fs+rUvg==, + } + '@pgsql/quotes@18.1.0': resolution: {integrity: sha512-wZcMP1QHiQbWWKOw4nfvZ74xUSz/9jqeSUXVnoR1saI5M0D+iYbiuOlfNDyYmziLwgEkCuSJLZK1dZ+eQCwgAA==} @@ -13090,6 +13105,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@pgsql/quotes@17.1.0': {} + '@pgsql/quotes@18.1.0': {} '@pgsql/transform@18.8.0':