Skip to content

Commit 75d1f22

Browse files
feat(oauth): integrate DB settings for state cookie and error redirect
- Add oauthStateMaxAge, oauthRequireVerifiedEmail, oauthErrorRedirectPath 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 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 589b6ad commit 75d1f22

3 files changed

Lines changed: 67 additions & 17 deletions

File tree

graphql/server/src/middleware/oauth.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,36 @@ 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+
/**
50+
* Parse PostgreSQL interval string to milliseconds.
51+
* Supports: '10 minutes', '1 hour', '00:10:00' (HH:MM:SS), etc.
52+
*/
53+
function parseIntervalToMs(interval: string | null | undefined): number {
54+
if (!interval) return DEFAULT_OAUTH_STATE_MAX_AGE;
55+
56+
// Handle HH:MM:SS format (PostgreSQL default interval output)
57+
const hhmmss = interval.match(/^(\d+):(\d+):(\d+)$/);
58+
if (hhmmss) {
59+
const hours = parseInt(hhmmss[1], 10);
60+
const minutes = parseInt(hhmmss[2], 10);
61+
const seconds = parseInt(hhmmss[3], 10);
62+
return (hours * 3600 + minutes * 60 + seconds) * 1000;
63+
}
64+
65+
// Handle "N unit" format (e.g., "10 minutes")
66+
const match = interval.match(/^(\d+)\s*(second|minute|hour|day)s?$/i);
67+
if (!match) return DEFAULT_OAUTH_STATE_MAX_AGE;
68+
const value = parseInt(match[1], 10);
69+
const unit = match[2].toLowerCase();
70+
const multipliers: Record<string, number> = {
71+
second: 1000,
72+
minute: 60 * 1000,
73+
hour: 60 * 60 * 1000,
74+
day: 24 * 60 * 60 * 1000,
75+
};
76+
return value * (multipliers[unit] || 60 * 1000);
77+
}
78+
4979
// =============================================================================
5080
// Signed State Utilities
5181
// =============================================================================
@@ -376,28 +406,32 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
376406
}
377407

378408
const providerConfig = await getIdentityProvider(ctx, modules, provider);
409+
const { authSettings } = modules;
410+
const errorRedirectPath =
411+
authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH;
412+
379413
if (!providerConfig) {
380414
log.warn(`[oauth] Provider ${provider} not found or not configured`);
381415
return redirectToError(
382416
res,
383417
baseUrl,
384-
DEFAULT_ERROR_REDIRECT_PATH,
418+
errorRedirectPath,
385419
'PROVIDER_NOT_CONFIGURED',
386420
provider,
387421
);
388422
}
389423

390-
const stateMaxAge = DEFAULT_OAUTH_STATE_MAX_AGE;
424+
const stateMaxAge = parseIntervalToMs(authSettings?.oauthStateMaxAge);
391425
const state = createSignedState(
392426
{ redirect_uri: redirectUri, provider },
393427
stateMaxAge,
394428
);
395429

396430
res.cookie(OAUTH_STATE_COOKIE, state, {
397-
httpOnly: true,
398-
secure: isProduction,
431+
httpOnly: authSettings?.cookieHttponly ?? true,
432+
secure: authSettings?.cookieSecure ?? isProduction,
399433
maxAge: stateMaxAge,
400-
sameSite: 'lax',
434+
sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax',
401435
});
402436

403437
const client = createOAuthClientForProvider(providerConfig, baseUrl);
@@ -485,8 +519,9 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
485519
);
486520
}
487521

522+
let modules: OAuthModules | null = null;
488523
try {
489-
const modules = await resolveOAuthModules(ctx);
524+
modules = await resolveOAuthModules(ctx);
490525
if (!modules) {
491526
log.error(
492527
`[oauth] Required modules not provisioned for ${provider}`,
@@ -500,6 +535,12 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
500535
);
501536
}
502537

538+
const { authSettings } = modules;
539+
const errorRedirectPath =
540+
authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH;
541+
const requireVerifiedEmail =
542+
authSettings?.oauthRequireVerifiedEmail ?? true;
543+
503544
const providerConfig = await getIdentityProvider(
504545
ctx,
505546
modules,
@@ -510,7 +551,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
510551
return redirectToError(
511552
res,
512553
baseUrl,
513-
DEFAULT_ERROR_REDIRECT_PATH,
554+
errorRedirectPath,
514555
'PROVIDER_NOT_CONFIGURED',
515556
provider,
516557
);
@@ -591,7 +632,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
591632
`[oauth] Account not found for ${profile.email}, attempting signup`,
592633
);
593634

594-
if (!emailVerified) {
635+
if (requireVerifiedEmail && !emailVerified) {
595636
log.warn(
596637
`[oauth] Rejecting unverified email for signup: ${profile.email}`,
597638
);
@@ -623,7 +664,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
623664
return redirectToError(
624665
res,
625666
baseUrl,
626-
DEFAULT_ERROR_REDIRECT_PATH,
667+
errorRedirectPath,
627668
'EMAIL_NOT_VERIFIED',
628669
provider,
629670
);
@@ -677,13 +718,10 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
677718
}
678719
} catch (error: any) {
679720
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-
);
721+
const fallbackPath =
722+
modules?.authSettings?.oauthErrorRedirectPath ||
723+
DEFAULT_ERROR_REDIRECT_PATH;
724+
redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider);
687725
}
688726
},
689727
);

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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
`;
@@ -50,6 +53,9 @@ interface AuthSettingsRow {
5053
remember_me_duration: string | null;
5154
enable_captcha: boolean;
5255
captcha_site_key: string | null;
56+
oauth_state_max_age: string | 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export interface AuthSettings {
9595
rememberMeDuration?: string | null;
9696
enableCaptcha?: boolean;
9797
captchaSiteKey?: string | null;
98+
oauthStateMaxAge?: string | null;
99+
oauthRequireVerifiedEmail?: boolean;
100+
oauthErrorRedirectPath?: string | null;
98101
}
99102

100103
export interface ApiStructure {

0 commit comments

Comments
 (0)