Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1f70db1
oauth related express context chagnes
Zetazzz Jun 12, 2026
97b9948
fixed ipm and types
Zetazzz Jun 14, 2026
3d145db
refactor(express-context): clarify OAuth module loaders and follow-ups
Zetazzz Jun 15, 2026
7e640d3
Add OAuth email verification handling and tests
Zetazzz Jun 16, 2026
3f0e4a6
Migrate GraphQL OAuth routes and admin API integration
Zetazzz Jun 16, 2026
77a2882
remove outdated oauth express middleware
Zetazzz Jun 16, 2026
5d52c48
abstract functions of pg interval and signed state
Zetazzz Jun 16, 2026
5f8b4de
remove cross origin handoff token logic
Zetazzz Jun 16, 2026
5166367
Merge branch 'main' into feat/oauth-reorg
Zetazzz Jun 16, 2026
f77d301
apply loaders to app setting auth middleware
Zetazzz Jun 16, 2026
1395ace
Revert "apply loaders to app setting auth middleware"
Zetazzz Jun 16, 2026
a813391
update followup
Zetazzz Jun 16, 2026
604d7b3
Reapply "apply loaders to app setting auth middleware"
Zetazzz Jun 16, 2026
2a2df1a
update oauth followup after app settings loader reapply
Zetazzz Jun 16, 2026
1a3c511
handle app setting update in middleware
Zetazzz Jun 16, 2026
e129f14
regulate JWT claims usage
Zetazzz Jun 16, 2026
288eed5
fix: update oauth lockfile
Zetazzz Jun 19, 2026
349175a
merge main into oauth branch
Zetazzz Jun 19, 2026
4f3136b
fix env snapshots
Zetazzz Jun 20, 2026
0e75f8e
feat(oauth): add runtime provider resolver
Zetazzz Jul 2, 2026
6ba9d84
fix(oauth): remove admin REST routes
Zetazzz Jul 2, 2026
2650cc5
feat(oauth): use runtime provider config
Zetazzz Jul 2, 2026
6ed7002
feat(oauth): support pkce and token auth methods
Zetazzz Jul 2, 2026
75f9b4a
fix(oauth): harden pkce identity runtime
Zetazzz Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/plan/oauth/oauth-implementation-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# OAuth Implementation Follow-up

## Identity Providers Module Scope

- Investigate the platform `identity_providers_module` record whose `scope` is currently `app`. For platform-level OAuth configuration, the expected scope appears to be `platform`; keeping it as `app` may be a provisioning or migration artifact and can make platform OAuth semantics ambiguous.

## Identity Auth Function Metadata

- Consider making `sign_in_identity_function` and `sign_up_identity_function` metadata-driven. These functions are generated by the user auth module, but the current express-context config uses hardcoded function names. A future database/schema update could expose these function names through module metadata, similar to other auth functions.

## Auth Settings Interval Parsing

- Fix cookie/session duration parsing after the OAuth loader work lands. `app_settings_auth.cookie_max_age`, `remember_me_duration`, and `oauth_state_max_age` are PostgreSQL `interval` columns; `pg` returns them as `PostgresInterval` objects such as `{ days: 14 }` or `{ minutes: 10 }`. The current GraphQL server cookie helper still parses these values as second strings with `parseInt`, so loader-backed auth settings can silently fall back to defaults. This is an existing cookie auth settings compatibility issue exposed by the OAuth/auth settings loader path and should be handled in a follow-up PR.

## Loader Cache Invalidation and TTL Semantics

- Revisit `createModuleLoader` cache expiration and invalidation semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior should be decided together with explicit invalidation for writes performed by our own systems.

- Known changes made through this process, such as admin APIs updating auth settings or identity providers, should invalidate the relevant loader cache after the database write succeeds. The current auth settings update path invalidates `authSettingsLoader`, but identity provider admin writes do not yet invalidate the cached OAuth provider config. Recommended shape: loaders own read, transform, cache, and invalidate; services or repositories own validate, authorize, write, audit, and post-write invalidate. For example, `identityProvidersService.updateProvider(ctx, input)` writes the provider config and then calls a targeted invalidation such as `registry.invalidate(ctx.databaseId, "identityProviders")` or the equivalent loader-specific invalidation API.

- Unknown external changes, such as manual SQL, migrations, or another service updating module configuration, cannot be invalidated precisely by this process. Baseline approach: keep `updateAgeOnGet: false` so TTL remains a bounded staleness window, then reload from the database on the next read after TTL expiry. If stronger freshness is needed later, add an optional lightweight fingerprint probe near loader resolution, for example `getFingerprint(ctx)` using `updated_at`, version, or checksum plus `revalidateAfterMs` as the minimum interval between probes. This lets loaders detect external changes faster than full TTL expiry without fully reloading config on every request.

- Recommendation: set `updateAgeOnGet` to `false`, add explicit post-write invalidation for known admin writes, and rely on TTL as the fallback for unknown external changes. Add fingerprint probing only if TTL-based staleness becomes too slow in practice.

## API Service Cache Loader Snapshots

- Revisit `graphql/server/src/middleware/api.ts` storing resolved loader values inside the cached `ApiStructure`. The API resolver currently resolves mutable module settings such as `authSettings`, `corsOrigins`, `databaseSettings`, `pubkeyChallengeSettings`, and `webauthnSettings`, then stores the whole API structure in `svcCache`, whose TTL is effectively long-lived. This can bypass each loader's own TTL and invalidation path; for example, `updateAuthSettings()` invalidates `authSettingsLoader`, but middleware that reads `req.api.authSettings` could still see the old value from `svcCache`. Consider narrowing `svcCache` to stable API routing fields only, resolving mutable module settings through `ctx.useModule(...)` at use sites, or adding coordinated `svcCache` invalidation whenever loader-backed settings are updated.

## Env Config Consolidation

- Consider moving existing CAPTCHA and upload environment variables into the shared `@pgpmjs/env` config surface in a separate cleanup PR. The reference OAuth branch added `RECAPTCHA_SECRET_KEY` and `MAX_UPLOAD_FILE_SIZE` to `PgpmOptions`, but this OAuth migration keeps those existing middleware paths on direct `process.env` reads to avoid widening the PR scope beyond OAuth/server admin APIs.
1 change: 1 addition & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
"useTx": false,
},
},
"oauth": {},
"pg": {
"database": "config-db",
"host": "override-host",
Expand Down
2 changes: 2 additions & 0 deletions graphql/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@
"@constructive-io/express-context": "workspace:^",
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@constructive-io/oauth": "workspace:^",
"@constructive-io/s3-utils": "workspace:^",
"@constructive-io/url-domains": "workspace:^",
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
"@pgpmjs/env": "workspace:^",
"@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",
Expand Down
335 changes: 335 additions & 0 deletions graphql/server/src/middleware/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
import express from 'express';
import http from 'http';
import type { AddressInfo } from 'net';
import {
createSignedState,
deriveCodeChallenge,
verifySignedState,
} from '@constructive-io/oauth';

import { createOAuthRoutes } from '../oauth';

const OAUTH_SECRET = 'test-oauth-state-secret';
const originalFetch = global.fetch;
const authQueryMock = jest.fn();

jest.mock('@pgpmjs/env', () => ({
getNodeEnv: jest.fn(() => 'test'),
getEnvVars: jest.fn(() => ({
oauth: {
secret: OAUTH_SECRET,
},
})),
}));

jest.mock('@pgpmjs/logger', () => ({
Logger: jest.fn(() => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
})),
}));

interface TestHttpResponse {
statusCode: number;
headers: http.IncomingHttpHeaders;
body: string;
}

interface OAuthStatePayload {
redirect_uri: string;
provider: string;
}

interface OAuthPkcePayload {
state: string;
provider: string;
code_verifier: string;
}

const providerConfig = {
slug: 'github',
kind: 'oauth2' as const,
displayName: 'GitHub',
enabled: true,
clientId: 'github-client-id',
clientSecret: 'github-client-secret',
authorizationUrl: 'https://github.example.test/login/oauth/authorize',
tokenUrl: 'https://github.example.test/login/oauth/access_token',
userinfoUrl: 'https://github.example.test/api/v3/user',
scopes: ['read:user', 'user:email'],
authorizationParams: {
prompt: 'select_account',
},
pkceEnabled: true,
};

afterEach(() => {
global.fetch = originalFetch;
authQueryMock.mockReset();
});

function createConstructiveContext() {
return {
withPgClient: jest.fn(async (fn: (client: { query: typeof authQueryMock }) => Promise<unknown>) =>
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<T>(
run: (baseUrl: string) => Promise<T>,
): Promise<T> {
const app = express();
app.use((req, _res, next) => {
(req as any).constructive = createConstructiveContext();
next();
});
app.use('/auth', createOAuthRoutes({} as any));

const server = await new Promise<http.Server>((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<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}

async function request(
url: string,
headers: Record<string, string> = {},
): Promise<TestHttpResponse> {
return new Promise((resolve, reject) => {
const req = http.request(url, { method: 'GET', headers }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
res.on('end', () => {
resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks).toString('utf8'),
});
});
});
req.on('error', reject);
req.end();
});
}

function getSetCookieValues(headers: http.IncomingHttpHeaders): string[] {
const setCookie = headers['set-cookie'];
if (!setCookie) return [];
return Array.isArray(setCookie) ? setCookie : [setCookie];
}

function readCookie(setCookies: string[], name: string): string {
const cookie = setCookies.find((value) => value.startsWith(`${name}=`));
if (!cookie) {
throw new Error(`Missing ${name} cookie`);
}
const value = cookie.split(';')[0].slice(name.length + 1);
return decodeURIComponent(value);
}

describe('OAuth routes', () => {
it('binds PKCE verifier to the signed state cookie without exposing it in the redirect URL', async () => {
await withOAuthServer(async (baseUrl) => {
const response = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`);

expect(response.statusCode).toBe(302);
const location = response.headers.location;
expect(location).toBeDefined();

const redirect = new URL(location!);
const setCookies = getSetCookieValues(response.headers);
const stateCookie = readCookie(setCookies, 'oauth_state');
const pkceCookie = readCookie(setCookies, 'oauth_pkce');

expect(redirect.origin).toBe('https://github.example.test');
expect(redirect.pathname).toBe('/login/oauth/authorize');
expect(redirect.searchParams.get('state')).toBe(stateCookie);
expect(redirect.searchParams.get('code_challenge_method')).toBe('S256');
expect(redirect.searchParams.get('prompt')).toBe('select_account');
expect(location).not.toContain('code_verifier');

const statePayload = verifySignedState<OAuthStatePayload>(stateCookie, {
secret: OAUTH_SECRET,
});
expect(statePayload).toMatchObject({
redirect_uri: '/dashboard',
provider: 'github',
});

const pkcePayload = verifySignedState<OAuthPkcePayload>(pkceCookie, {
secret: OAUTH_SECRET,
});
expect(pkcePayload).toMatchObject({
state: stateCookie,
provider: 'github',
});
expect(pkcePayload!.code_verifier).toHaveLength(43);
expect(redirect.searchParams.get('code_challenge')).toBe(
deriveCodeChallenge(pkcePayload!.code_verifier),
);

expect(
setCookies.find((value) => value.startsWith('oauth_state=')),
).toContain('HttpOnly');
expect(
setCookies.find((value) => value.startsWith('oauth_pkce=')),
).toContain('HttpOnly');
});
});

it('rejects callback requests when the PKCE verifier is not bound to the returned state', async () => {
await withOAuthServer(async (baseUrl) => {
const stateCookie = createSignedState<OAuthStatePayload>(
{ redirect_uri: '/dashboard', provider: 'github' },
{ secret: OAUTH_SECRET, maxAgeMs: 60_000 },
);
const pkceCookie = createSignedState<OAuthPkcePayload>(
{
state: 'different-state',
provider: 'github',
code_verifier: 'test-code-verifier',
},
{ secret: OAUTH_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('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<OAuthPkcePayload>(pkceCookie, {
secret: OAUTH_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);
});
});
});
Loading