Skip to content

Commit 937e971

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 937e971

3 files changed

Lines changed: 56 additions & 17 deletions

File tree

graphql/server/src/middleware/oauth.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,25 @@ 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', '30 seconds', etc.
52+
*/
53+
function parseIntervalToMs(interval: string | null | undefined): number {
54+
if (!interval) return DEFAULT_OAUTH_STATE_MAX_AGE;
55+
const match = interval.match(/^(\d+)\s*(second|minute|hour|day)s?$/i);
56+
if (!match) return DEFAULT_OAUTH_STATE_MAX_AGE;
57+
const value = parseInt(match[1], 10);
58+
const unit = match[2].toLowerCase();
59+
const multipliers: Record<string, number> = {
60+
second: 1000,
61+
minute: 60 * 1000,
62+
hour: 60 * 60 * 1000,
63+
day: 24 * 60 * 60 * 1000,
64+
};
65+
return value * (multipliers[unit] || 60 * 1000);
66+
}
67+
4968
// =============================================================================
5069
// Signed State Utilities
5170
// =============================================================================
@@ -376,28 +395,32 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
376395
}
377396

378397
const providerConfig = await getIdentityProvider(ctx, modules, provider);
398+
const { authSettings } = modules;
399+
const errorRedirectPath =
400+
authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH;
401+
379402
if (!providerConfig) {
380403
log.warn(`[oauth] Provider ${provider} not found or not configured`);
381404
return redirectToError(
382405
res,
383406
baseUrl,
384-
DEFAULT_ERROR_REDIRECT_PATH,
407+
errorRedirectPath,
385408
'PROVIDER_NOT_CONFIGURED',
386409
provider,
387410
);
388411
}
389412

390-
const stateMaxAge = DEFAULT_OAUTH_STATE_MAX_AGE;
413+
const stateMaxAge = parseIntervalToMs(authSettings?.oauthStateMaxAge);
391414
const state = createSignedState(
392415
{ redirect_uri: redirectUri, provider },
393416
stateMaxAge,
394417
);
395418

396419
res.cookie(OAUTH_STATE_COOKIE, state, {
397-
httpOnly: true,
398-
secure: isProduction,
420+
httpOnly: authSettings?.cookieHttponly ?? true,
421+
secure: authSettings?.cookieSecure ?? isProduction,
399422
maxAge: stateMaxAge,
400-
sameSite: 'lax',
423+
sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax',
401424
});
402425

403426
const client = createOAuthClientForProvider(providerConfig, baseUrl);
@@ -485,8 +508,9 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
485508
);
486509
}
487510

511+
let modules: OAuthModules | null = null;
488512
try {
489-
const modules = await resolveOAuthModules(ctx);
513+
modules = await resolveOAuthModules(ctx);
490514
if (!modules) {
491515
log.error(
492516
`[oauth] Required modules not provisioned for ${provider}`,
@@ -500,6 +524,12 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
500524
);
501525
}
502526

527+
const { authSettings } = modules;
528+
const errorRedirectPath =
529+
authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH;
530+
const requireVerifiedEmail =
531+
authSettings?.oauthRequireVerifiedEmail ?? true;
532+
503533
const providerConfig = await getIdentityProvider(
504534
ctx,
505535
modules,
@@ -510,7 +540,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
510540
return redirectToError(
511541
res,
512542
baseUrl,
513-
DEFAULT_ERROR_REDIRECT_PATH,
543+
errorRedirectPath,
514544
'PROVIDER_NOT_CONFIGURED',
515545
provider,
516546
);
@@ -591,7 +621,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
591621
`[oauth] Account not found for ${profile.email}, attempting signup`,
592622
);
593623

594-
if (!emailVerified) {
624+
if (requireVerifiedEmail && !emailVerified) {
595625
log.warn(
596626
`[oauth] Rejecting unverified email for signup: ${profile.email}`,
597627
);
@@ -623,7 +653,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
623653
return redirectToError(
624654
res,
625655
baseUrl,
626-
DEFAULT_ERROR_REDIRECT_PATH,
656+
errorRedirectPath,
627657
'EMAIL_NOT_VERIFIED',
628658
provider,
629659
);
@@ -677,13 +707,10 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
677707
}
678708
} catch (error: any) {
679709
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-
);
710+
const fallbackPath =
711+
modules?.authSettings?.oauthErrorRedirectPath ||
712+
DEFAULT_ERROR_REDIRECT_PATH;
713+
redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider);
687714
}
688715
},
689716
);

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)