Skip to content

Commit 998f892

Browse files
feat(oauth): add identity pre-check with connectedAccounts loader
Replace try/catch IDENTITY_ACCOUNT_NOT_FOUND pattern with explicit identity existence check using connected_accounts table query. Changes: - Add connectedAccountsLoader to resolve connected_accounts_module config - Query connected_accounts via ctx.pool (bypasses RLS) before sign-in - Use if/else instead of exception handling for sign_in vs sign_up flow - Add sessionCredentialsSchemaName to userAuthLoader (JOINs with session_credentials_table_id for correct schema resolution) - Add parseIntervalToSeconds in cookie.ts for PgInterval handling - Export ConnectedAccountsConfig, PgInterval types Addresses reviewer feedback from PR #1141 and #1220. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 317f9c4 commit 998f892

8 files changed

Lines changed: 156 additions & 46 deletions

File tree

graphql/server/src/middleware/cookie.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
11
import type { Request, Response } from 'express';
2-
import type { AuthSettings } from '../types';
2+
import type { AuthSettings, PgInterval } from '../types';
33

44
export const SESSION_COOKIE_NAME = 'constructive_session';
55
export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
66

77
const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
88

9+
const parseIntervalToSeconds = (interval: string | PgInterval | null | undefined): number | null => {
10+
if (!interval) return null;
11+
if (typeof interval === 'string') {
12+
const parsed = parseInt(interval, 10);
13+
return isNaN(parsed) ? null : parsed;
14+
}
15+
let totalSeconds = 0;
16+
if (interval.years) totalSeconds += interval.years * 365 * 24 * 60 * 60;
17+
if (interval.months) totalSeconds += interval.months * 30 * 24 * 60 * 60;
18+
if (interval.days) totalSeconds += interval.days * 24 * 60 * 60;
19+
if (interval.hours) totalSeconds += interval.hours * 60 * 60;
20+
if (interval.minutes) totalSeconds += interval.minutes * 60;
21+
if (interval.seconds) totalSeconds += interval.seconds;
22+
if (interval.milliseconds) totalSeconds += interval.milliseconds / 1000;
23+
return totalSeconds > 0 ? totalSeconds : null;
24+
};
25+
926
export interface CookieConfig {
1027
secure: boolean;
1128
sameSite: 'strict' | 'lax' | 'none';
@@ -25,11 +42,11 @@ export const getSessionCookieConfig = (
2542
const DEFAULT_MAX_AGE = 86400; // 24 hours
2643
let maxAge = DEFAULT_MAX_AGE;
2744
if (rememberMe && authSettings?.rememberMeDuration) {
28-
const parsed = parseInt(authSettings.rememberMeDuration, 10);
29-
if (!isNaN(parsed)) maxAge = parsed;
45+
const parsed = parseIntervalToSeconds(authSettings.rememberMeDuration);
46+
if (parsed !== null) maxAge = parsed;
3047
} else if (authSettings?.cookieMaxAge) {
31-
const parsed = parseInt(authSettings.cookieMaxAge, 10);
32-
if (!isNaN(parsed)) maxAge = parsed;
48+
const parsed = parseIntervalToSeconds(authSettings.cookieMaxAge);
49+
if (parsed !== null) maxAge = parsed;
3350
}
3451

3552
return {

graphql/server/src/middleware/oauth.ts

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getNodeEnv } from '@pgpmjs/env';
2525
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
2626
import type {
2727
AuthSettings,
28+
ConnectedAccountsConfig,
2829
ConstructiveContext,
2930
EncryptedSecretsConfig,
3031
IdentityProvidersConfig,
@@ -172,24 +173,26 @@ interface OAuthModules {
172173
encryptedSecrets: EncryptedSecretsConfig;
173174
userAuth: UserAuthConfig;
174175
authSettings: AuthSettings | undefined;
176+
connectedAccounts: ConnectedAccountsConfig | undefined;
175177
}
176178

177179
async function resolveOAuthModules(
178180
ctx: ConstructiveContext,
179181
): Promise<OAuthModules | null> {
180-
const [identityProviders, encryptedSecrets, userAuth, authSettings] =
182+
const [identityProviders, encryptedSecrets, userAuth, authSettings, connectedAccounts] =
181183
await Promise.all([
182184
ctx.useModule('identityProviders'),
183185
ctx.useModule('encryptedSecrets'),
184186
ctx.useModule('userAuth'),
185187
ctx.useModule('authSettings'),
188+
ctx.useModule('connectedAccounts'),
186189
]);
187190

188191
if (!identityProviders || !encryptedSecrets || !userAuth) {
189192
return null;
190193
}
191194

192-
return { identityProviders, encryptedSecrets, userAuth, authSettings };
195+
return { identityProviders, encryptedSecrets, userAuth, authSettings, connectedAccounts };
193196
}
194197

195198
// =============================================================================
@@ -601,7 +604,39 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
601604
}
602605

603606
const userAgent = req.get('user-agent') || '';
604-
const { userAuth } = modules;
607+
const { identityProviders, connectedAccounts } = modules;
608+
const authPrivateSchema = identityProviders.privateSchemaName;
609+
610+
// Check if identity exists using ctx.pool (bypasses RLS)
611+
let identityExists = false;
612+
if (connectedAccounts) {
613+
const checkSql = `
614+
SELECT 1 FROM "${connectedAccounts.privateSchemaName}"."${connectedAccounts.tableName}"
615+
WHERE service = $1 AND identifier = $2
616+
LIMIT 1
617+
`;
618+
const checkResult = await ctx.pool.query(checkSql, [
619+
profile.provider,
620+
profile.providerId,
621+
]);
622+
identityExists = checkResult.rows.length > 0;
623+
}
624+
625+
const emailVerified = isEmailVerified(profile);
626+
627+
// For signup, check email verification requirement before entering transaction
628+
if (!identityExists && requireVerifiedEmail && !emailVerified) {
629+
log.warn(
630+
`[oauth] Rejecting unverified email for signup: ${profile.email}`,
631+
);
632+
return redirectToError(
633+
res,
634+
baseUrl,
635+
errorRedirectPath,
636+
'EMAIL_NOT_VERIFIED',
637+
provider,
638+
);
639+
}
605640

606641
// Use withPgClient to run sign_in/sign_up within a properly scoped
607642
// RLS transaction. pgSettings (role, claims, request_id) are applied
@@ -615,7 +650,6 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
615650
[userAgent, targetOrigin],
616651
);
617652

618-
const emailVerified = isEmailVerified(profile);
619653
const details = {
620654
provider: profile.provider,
621655
sub: profile.providerId,
@@ -626,15 +660,13 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
626660
raw_userinfo: profile.raw,
627661
};
628662

629-
// sign_in_identity lives in the userAuth schema, NOT assumed to
630-
// be in the RLS privateSchema.
631-
const signInSql = `
632-
SELECT * FROM "${userAuth.schemaName}".sign_in_identity(
633-
$1::text, $2::text, $3::jsonb, $4::text, 'access_token'::text, $5::boolean, $6::text
634-
)
635-
`;
636-
637-
try {
663+
if (identityExists) {
664+
// Identity exists, sign in
665+
const signInSql = `
666+
SELECT * FROM "${authPrivateSchema}".sign_in_identity(
667+
$1::text, $2::text, $3::jsonb, $4::text, 'access_token'::text, $5::boolean, $6::text
668+
)
669+
`;
638670
const signInResult = await client.query(signInSql, [
639671
profile.provider,
640672
profile.providerId,
@@ -644,31 +676,17 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
644676
deviceToken,
645677
]);
646678
return signInResult.rows[0] || {};
647-
} catch (err: any) {
648-
const errorMessage = err.message || '';
649-
650-
if (!errorMessage.includes('IDENTITY_ACCOUNT_NOT_FOUND')) {
651-
throw err;
652-
}
653-
679+
} else {
680+
// Identity doesn't exist, sign up
654681
log.info(
655682
`[oauth] Account not found for ${profile.email}, attempting signup`,
656683
);
657684

658-
if (requireVerifiedEmail && !emailVerified) {
659-
log.warn(
660-
`[oauth] Rejecting unverified email for signup: ${profile.email}`,
661-
);
662-
return { _error: 'EMAIL_NOT_VERIFIED' } as any;
663-
}
664-
665-
// sign_up_identity also lives in the userAuth schema
666685
const signUpSql = `
667-
SELECT * FROM "${userAuth.schemaName}".sign_up_identity(
686+
SELECT * FROM "${authPrivateSchema}".sign_up_identity(
668687
$1::text, $2::text, $3::text, $4::jsonb, 'access_token'::text, $5::boolean, $6::text
669688
)
670689
`;
671-
672690
const signUpResult = await client.query(signUpSql, [
673691
profile.provider,
674692
profile.providerId,
@@ -682,17 +700,6 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router {
682700
},
683701
);
684702

685-
// Handle error sentinels from within the transaction
686-
if ((result as any)._error === 'EMAIL_NOT_VERIFIED') {
687-
return redirectToError(
688-
res,
689-
baseUrl,
690-
errorRedirectPath,
691-
'EMAIL_NOT_VERIFIED',
692-
provider,
693-
);
694-
}
695-
696703
// Handle MFA required
697704
if (result.mfa_required && result.mfa_challenge_token) {
698705
log.info(`[oauth] MFA required for ${profile.email}`);

graphql/server/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type {
1111
CorsModuleData,
1212
DatabaseSettings,
1313
GenericModuleData,
14+
PgInterval,
1415
PubkeyChallengeSettings,
1516
PublicKeyChallengeData,
1617
RlsModule,

packages/express-context/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ export type {
5252
GenericModuleData,
5353
InferenceLogConfig,
5454
PublicKeyChallengeData,
55+
PgInterval,
5556
PubkeyChallengeSettings,
5657
RlsModule,
58+
ConnectedAccountsConfig,
5759
EncryptedSecretsConfig,
5860
IdentityProvidersConfig,
5961
UserAuthConfig,
@@ -98,6 +100,7 @@ export {
98100
encryptedSecretsLoader,
99101
userAuthLoader,
100102
identityProvidersLoader,
103+
connectedAccountsLoader,
101104
} from './loaders';
102105

103106
// Side-effect: Express type augmentation
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Connected Accounts Module Loader
3+
*
4+
* Resolves the connected_accounts_module config from metaschema_modules_public.
5+
* Provides schema names for querying OAuth identity associations.
6+
*/
7+
8+
import type { LoaderContext, ModuleLoader } from './types';
9+
import { createModuleLoader } from './create-loader';
10+
11+
// ─── Types ──────────────────────────────────────────────────────────────────
12+
13+
export interface ConnectedAccountsConfig {
14+
schemaName: string;
15+
privateSchemaName: string;
16+
tableName: string;
17+
}
18+
19+
// ─── SQL ────────────────────────────────────────────────────────────────────
20+
21+
const CONNECTED_ACCOUNTS_MODULE_SQL = `
22+
SELECT
23+
s.schema_name,
24+
ps.schema_name AS private_schema_name,
25+
cam.table_name
26+
FROM metaschema_modules_public.connected_accounts_module cam
27+
JOIN metaschema_public.schema s ON s.id = cam.schema_id
28+
JOIN metaschema_public.schema ps ON ps.id = cam.private_schema_id
29+
WHERE cam.database_id = $1
30+
LIMIT 1
31+
`;
32+
33+
// ─── Row Types ──────────────────────────────────────────────────────────────
34+
35+
interface ConnectedAccountsModuleRow {
36+
schema_name: string;
37+
private_schema_name: string;
38+
table_name: string;
39+
}
40+
41+
// ─── Loader ─────────────────────────────────────────────────────────────────
42+
43+
export const connectedAccountsLoader: ModuleLoader<ConnectedAccountsConfig> =
44+
createModuleLoader<ConnectedAccountsConfig>({
45+
name: 'connectedAccounts',
46+
ttlMs: 5 * 60_000,
47+
async resolve(ctx: LoaderContext) {
48+
const { tenantPool, databaseId } = ctx;
49+
50+
const result = await tenantPool.query<ConnectedAccountsModuleRow>(
51+
CONNECTED_ACCOUNTS_MODULE_SQL,
52+
[databaseId],
53+
);
54+
const row = result.rows[0];
55+
if (!row) return undefined;
56+
57+
return {
58+
schemaName: row.schema_name,
59+
privateSchemaName: row.private_schema_name,
60+
tableName: row.table_name,
61+
};
62+
},
63+
});

packages/express-context/src/loaders/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* - encryptedSecrets (metaschema_modules_public.config_secrets_org_module)
1616
* - userAuth (metaschema_modules_public.user_auth_module)
1717
* - identityProviders (metaschema_modules_public.identity_providers_module)
18+
* - connectedAccounts (metaschema_modules_public.connected_accounts_module)
1819
*
1920
* To add a new per-db lookup, implement a ModuleLoader and register it:
2021
*
@@ -53,6 +54,7 @@ export { agentChatLoader } from './agent-chat';
5354
export { encryptedSecretsLoader } from './encrypted-secrets';
5455
export { userAuthLoader } from './user-auth';
5556
export { identityProvidersLoader } from './identity-providers';
57+
export { connectedAccountsLoader } from './connected-accounts';
5658

5759
/**
5860
* Convenience: create a registry pre-loaded with all built-in loaders.
@@ -70,6 +72,7 @@ import { agentChatLoader } from './agent-chat';
7072
import { encryptedSecretsLoader } from './encrypted-secrets';
7173
import { userAuthLoader } from './user-auth';
7274
import { identityProvidersLoader } from './identity-providers';
75+
import { connectedAccountsLoader } from './connected-accounts';
7376

7477
export function createDefaultRegistry() {
7578
const registry = createLoaderRegistry();
@@ -85,5 +88,6 @@ export function createDefaultRegistry() {
8588
registry.register(encryptedSecretsLoader);
8689
registry.register(userAuthLoader);
8790
registry.register(identityProvidersLoader);
91+
registry.register(connectedAccountsLoader);
8892
return registry;
8993
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createModuleLoader } from './create-loader';
1313

1414
export interface UserAuthConfig {
1515
schemaName: string;
16+
sessionCredentialsSchemaName: string;
1617
signInFunction: string;
1718
signUpFunction: string;
1819
signOutFunction: string;
@@ -26,6 +27,7 @@ export interface UserAuthConfig {
2627
const USER_AUTH_MODULE_SQL = `
2728
SELECT
2829
s.schema_name,
30+
sc_schema.schema_name AS session_credentials_schema_name,
2931
uam.sign_in_function,
3032
uam.sign_up_function,
3133
uam.sign_out_function,
@@ -34,6 +36,8 @@ const USER_AUTH_MODULE_SQL = `
3436
uam.extend_token_expires
3537
FROM metaschema_modules_public.user_auth_module uam
3638
JOIN metaschema_public.schema s ON s.id = uam.schema_id
39+
LEFT JOIN metaschema_public.table sc_table ON sc_table.id = uam.session_credentials_table_id
40+
LEFT JOIN metaschema_public.schema sc_schema ON sc_schema.id = sc_table.schema_id
3741
WHERE uam.database_id = $1
3842
LIMIT 1
3943
`;
@@ -42,6 +46,7 @@ const USER_AUTH_MODULE_SQL = `
4246

4347
interface UserAuthModuleRow {
4448
schema_name: string;
49+
session_credentials_schema_name: string | null;
4550
sign_in_function: string;
4651
sign_up_function: string;
4752
sign_out_function: string;
@@ -68,6 +73,8 @@ export const userAuthLoader: ModuleLoader<UserAuthConfig> =
6873

6974
return {
7075
schemaName: row.schema_name,
76+
sessionCredentialsSchemaName:
77+
row.session_credentials_schema_name || row.schema_name,
7178
signInFunction: row.sign_in_function,
7279
signUpFunction: row.sign_up_function,
7380
signOutFunction: row.sign_out_function,

0 commit comments

Comments
 (0)