Skip to content

Commit c6e6a43

Browse files
feat(oauth): integrate DB settings for state cookie and error redirect
- Add PgInterval type and oauthStateMaxAge/RequireVerifiedEmail/ErrorRedirectPath to AuthSettings - Update authSettingsLoader to SELECT OAuth fields from app_settings_auth - Replace hardcoded DEFAULT_OAUTH_STATE_MAX_AGE with authSettings.oauthStateMaxAge - Replace hardcoded DEFAULT_ERROR_REDIRECT_PATH with authSettings.oauthErrorRedirectPath - Use authSettings.oauthRequireVerifiedEmail to control signup email verification - Apply authSettings cookie config (httpOnly, secure, sameSite) to OAuth state cookie - Handle pg interval objects and HH:MM:SS string format in parseIntervalToMs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 589b6ad commit c6e6a43

3 files changed

Lines changed: 105 additions & 22 deletions

File tree

graphql/server/src/middleware/oauth.ts

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,59 @@ const OAUTH_STATE_COOKIE = 'oauth_state';
4646
const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
4747
const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error';
4848

49+
interface PgInterval {
50+
years?: number;
51+
months?: number;
52+
days?: number;
53+
hours?: number;
54+
minutes?: number;
55+
seconds?: number;
56+
milliseconds?: number;
57+
}
58+
59+
/**
60+
* Parse PostgreSQL interval to milliseconds.
61+
* Handles: pg library object {minutes: 10}, string '10 minutes', '00:10:00'
62+
*/
63+
function parseIntervalToMs(
64+
interval: string | PgInterval | null | undefined,
65+
): number {
66+
if (!interval) return DEFAULT_OAUTH_STATE_MAX_AGE;
67+
68+
// Handle pg library interval object (e.g., {minutes: 10})
69+
if (typeof interval === 'object') {
70+
const ms =
71+
(interval.days || 0) * 24 * 60 * 60 * 1000 +
72+
(interval.hours || 0) * 60 * 60 * 1000 +
73+
(interval.minutes || 0) * 60 * 1000 +
74+
(interval.seconds || 0) * 1000 +
75+
(interval.milliseconds || 0);
76+
return ms || DEFAULT_OAUTH_STATE_MAX_AGE;
77+
}
78+
79+
// Handle HH:MM:SS format (PostgreSQL default interval output)
80+
const hhmmss = interval.match(/^(\d+):(\d+):(\d+)$/);
81+
if (hhmmss) {
82+
const hours = parseInt(hhmmss[1], 10);
83+
const minutes = parseInt(hhmmss[2], 10);
84+
const seconds = parseInt(hhmmss[3], 10);
85+
return (hours * 3600 + minutes * 60 + seconds) * 1000;
86+
}
87+
88+
// Handle "N unit" format (e.g., "10 minutes")
89+
const match = interval.match(/^(\d+)\s*(second|minute|hour|day)s?$/i);
90+
if (!match) return DEFAULT_OAUTH_STATE_MAX_AGE;
91+
const value = parseInt(match[1], 10);
92+
const unit = match[2].toLowerCase();
93+
const multipliers: Record<string, number> = {
94+
second: 1000,
95+
minute: 60 * 1000,
96+
hour: 60 * 60 * 1000,
97+
day: 24 * 60 * 60 * 1000,
98+
};
99+
return value * (multipliers[unit] || 60 * 1000);
100+
}
101+
49102
// =============================================================================
50103
// Signed State Utilities
51104
// =============================================================================
@@ -376,28 +429,32 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
376429
}
377430

378431
const providerConfig = await getIdentityProvider(ctx, modules, provider);
432+
const { authSettings } = modules;
433+
const errorRedirectPath =
434+
authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH;
435+
379436
if (!providerConfig) {
380437
log.warn(`[oauth] Provider ${provider} not found or not configured`);
381438
return redirectToError(
382439
res,
383440
baseUrl,
384-
DEFAULT_ERROR_REDIRECT_PATH,
441+
errorRedirectPath,
385442
'PROVIDER_NOT_CONFIGURED',
386443
provider,
387444
);
388445
}
389446

390-
const stateMaxAge = DEFAULT_OAUTH_STATE_MAX_AGE;
447+
const stateMaxAge = parseIntervalToMs(authSettings?.oauthStateMaxAge);
391448
const state = createSignedState(
392449
{ redirect_uri: redirectUri, provider },
393450
stateMaxAge,
394451
);
395452

396453
res.cookie(OAUTH_STATE_COOKIE, state, {
397-
httpOnly: true,
398-
secure: isProduction,
454+
httpOnly: authSettings?.cookieHttponly ?? true,
455+
secure: authSettings?.cookieSecure ?? isProduction,
399456
maxAge: stateMaxAge,
400-
sameSite: 'lax',
457+
sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax',
401458
});
402459

403460
const client = createOAuthClientForProvider(providerConfig, baseUrl);
@@ -485,8 +542,9 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
485542
);
486543
}
487544

545+
let modules: OAuthModules | null = null;
488546
try {
489-
const modules = await resolveOAuthModules(ctx);
547+
modules = await resolveOAuthModules(ctx);
490548
if (!modules) {
491549
log.error(
492550
`[oauth] Required modules not provisioned for ${provider}`,
@@ -500,6 +558,12 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
500558
);
501559
}
502560

561+
const { authSettings } = modules;
562+
const errorRedirectPath =
563+
authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH;
564+
const requireVerifiedEmail =
565+
authSettings?.oauthRequireVerifiedEmail ?? true;
566+
503567
const providerConfig = await getIdentityProvider(
504568
ctx,
505569
modules,
@@ -510,7 +574,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
510574
return redirectToError(
511575
res,
512576
baseUrl,
513-
DEFAULT_ERROR_REDIRECT_PATH,
577+
errorRedirectPath,
514578
'PROVIDER_NOT_CONFIGURED',
515579
provider,
516580
);
@@ -591,7 +655,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
591655
`[oauth] Account not found for ${profile.email}, attempting signup`,
592656
);
593657

594-
if (!emailVerified) {
658+
if (requireVerifiedEmail && !emailVerified) {
595659
log.warn(
596660
`[oauth] Rejecting unverified email for signup: ${profile.email}`,
597661
);
@@ -623,7 +687,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
623687
return redirectToError(
624688
res,
625689
baseUrl,
626-
DEFAULT_ERROR_REDIRECT_PATH,
690+
errorRedirectPath,
627691
'EMAIL_NOT_VERIFIED',
628692
provider,
629693
);
@@ -677,13 +741,10 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
677741
}
678742
} catch (error: any) {
679743
log.error(`[oauth] Callback failed for ${provider}:`, error);
680-
redirectToError(
681-
res,
682-
baseUrl,
683-
DEFAULT_ERROR_REDIRECT_PATH,
684-
'CALLBACK_FAILED',
685-
provider,
686-
);
744+
const fallbackPath =
745+
modules?.authSettings?.oauthErrorRedirectPath ||
746+
DEFAULT_ERROR_REDIRECT_PATH;
747+
redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider);
687748
}
688749
},
689750
);

packages/express-context/src/loaders/auth-settings.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* database rather than the services database.
1111
*/
1212

13-
import type { AuthSettings } from '../types';
13+
import type { AuthSettings, PgInterval } from '../types';
1414
import type { LoaderContext, ModuleLoader } from './types';
1515
import { createModuleLoader } from './create-loader';
1616

@@ -33,7 +33,10 @@ const buildAuthSettingsQuery = (schemaName: string, tableName: string) => `
3333
cookie_path,
3434
remember_me_duration,
3535
enable_captcha,
36-
captcha_site_key
36+
captcha_site_key,
37+
oauth_state_max_age,
38+
oauth_require_verified_email,
39+
oauth_error_redirect_path
3740
FROM "${schemaName}"."${tableName}"
3841
LIMIT 1
3942
`;
@@ -45,11 +48,14 @@ interface AuthSettingsRow {
4548
cookie_samesite: string;
4649
cookie_domain: string | null;
4750
cookie_httponly: boolean;
48-
cookie_max_age: string | null;
51+
cookie_max_age: string | PgInterval | null;
4952
cookie_path: string;
50-
remember_me_duration: string | null;
53+
remember_me_duration: string | PgInterval | null;
5154
enable_captcha: boolean;
5255
captcha_site_key: string | null;
56+
oauth_state_max_age: string | PgInterval | null;
57+
oauth_require_verified_email: boolean;
58+
oauth_error_redirect_path: string | null;
5359
}
5460

5561
// ─── Loader ─────────────────────────────────────────────────────────────────
@@ -84,6 +90,9 @@ export const authSettingsLoader: ModuleLoader<AuthSettings> = createModuleLoader
8490
rememberMeDuration: row.remember_me_duration,
8591
enableCaptcha: row.enable_captcha,
8692
captchaSiteKey: row.captcha_site_key,
93+
oauthStateMaxAge: row.oauth_state_max_age,
94+
oauthRequireVerifiedEmail: row.oauth_require_verified_email,
95+
oauthErrorRedirectPath: row.oauth_error_redirect_path,
8796
};
8897
},
8998
});

packages/express-context/src/types.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,29 @@ export interface RlsModule {
8585
currentUserAgent: string;
8686
}
8787

88+
export interface PgInterval {
89+
years?: number;
90+
months?: number;
91+
days?: number;
92+
hours?: number;
93+
minutes?: number;
94+
seconds?: number;
95+
milliseconds?: number;
96+
}
97+
8898
export interface AuthSettings {
8999
cookieSecure?: boolean;
90100
cookieSamesite?: string;
91101
cookieDomain?: string | null;
92102
cookieHttponly?: boolean;
93-
cookieMaxAge?: string | null;
103+
cookieMaxAge?: string | PgInterval | null;
94104
cookiePath?: string;
95-
rememberMeDuration?: string | null;
105+
rememberMeDuration?: string | PgInterval | null;
96106
enableCaptcha?: boolean;
97107
captchaSiteKey?: string | null;
108+
oauthStateMaxAge?: string | PgInterval | null;
109+
oauthRequireVerifiedEmail?: boolean;
110+
oauthErrorRedirectPath?: string | null;
98111
}
99112

100113
export interface ApiStructure {

0 commit comments

Comments
 (0)