feat: OAuth middleware with module loaders for identity/secrets/auth config#1220
feat: OAuth middleware with module loaders for identity/secrets/auth config#1220pyramation wants to merge 12 commits into
Conversation
…config Replace manual SQL queries and hardcoded schema assumptions in OAuth middleware with module loaders from express-context: New loaders: - encryptedSecretsLoader: resolves encrypted_secrets schema from metaschema_modules_public.encrypted_secrets_module - userAuthLoader: resolves user_auth_module for sign_in/sign_up function names and schema (no longer assumes privateSchema) - identityProvidersLoader: resolves identity_providers_module for provider config table location OAuth middleware changes: - Uses req.constructive.useModule() for all schema lookups - Uses req.constructive.withPgClient() for properly scoped RLS transactions (replaces manual set_config calls) - Removes express-rate-limit (DB already handles rate limiting) - Uses getNodeEnv() instead of process.env.NODE_ENV - Extracts email_verified from raw provider profile data - Passes loaders registry to createContextMiddleware in server.ts Addresses review comments from PR #1141.
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
937e971 to
75d1f22
Compare
- 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>
75d1f22 to
c6e6a43
Compare
The loader was querying non-existent encrypted_secrets_module table. Changed to use config_secrets_org_module which points to app_secrets table - the correct location for OAuth client secrets. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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>
…oader JOIN session_credentials_table_id to resolve the correct schema where session_credentials table lives, instead of assuming it's in the same schema as the auth functions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add loader for connected_accounts_module to query identity associations. Used by OAuth middleware to check if an identity exists before attempting sign_in_identity (avoids catching IDENTITY_ACCOUNT_NOT_FOUND exception). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add parseIntervalToSeconds to handle PostgreSQL interval objects returned by pg library for cookieMaxAge and rememberMeDuration fields. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace try/catch IDENTITY_ACCOUNT_NOT_FOUND pattern with explicit identity existence check. Query connected_accounts table using ctx.pool (bypasses RLS) before entering the sign-in transaction. Benefits: - Cleaner control flow (if/else instead of exception handling) - Email verification check happens before transaction starts - Avoids transaction abort on expected "not found" case Addresses reviewer feedback from PR #1141 and #1220. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
998f892 to
b450bd0
Compare
PR #1141 / #1220 - Issues AddressedIssue 1: Don't hardcode schema names, use loadersReviewer feedback: Should not assume
-- Before: assumed session_credentials in userAuth.schemaName
-- After: JOIN to find correct schema
LEFT JOIN metaschema_public.table sc_table ON sc_table.id = uam.session_credentials_table_id
LEFT JOIN metaschema_public.schema sc_schema ON sc_schema.id = sc_table.schema_idIssue 2: Don't catch
|
| Commit | Resolution |
|---|---|
c310328e0 |
New connectedAccountsLoader provides schema/table info for connected_accounts |
b450bd085 |
oauth.ts callback queries identity existence via ctx.pool before sign-in |
// Before: try sign_in → catch IDENTITY_ACCOUNT_NOT_FOUND → sign_up
// After: query first, then decide
let identityExists = false;
if (connectedAccounts) {
const checkSql = \`
SELECT 1 FROM "\${connectedAccounts.privateSchemaName}"."\${connectedAccounts.tableName}"
WHERE service = \$1 AND identifier = \$2
LIMIT 1
\`;
const checkResult = await ctx.pool.query(checkSql, [profile.provider, profile.providerId]);
identityExists = checkResult.rows.length > 0;
}
if (identityExists) {
// sign_in_identity
} else {
// sign_up_identity
}Why ctx.pool: Bypasses RLS since anonymous users cannot query connected_accounts table
Issue 3: session_credentials schema cannot be assumed
Reviewer feedback (PR #1220): session_credentials table may not be in userAuth.schemaName
| Commit | Resolution |
|---|---|
ebe0dafd7 |
userAuthLoader adds sessionCredentialsSchemaName field |
ebe0dafd7 |
SQL JOINs session_credentials_table_id → metaschema_public.table → metaschema_public.schema |
ebe0dafd7 |
Falls back to schema_name if JOIN result is null |
return {
schemaName: row.schema_name,
sessionCredentialsSchemaName:
row.session_credentials_schema_name || row.schema_name, // fallback
// ...
};Issue 4: PgInterval handling
Problem: PostgreSQL interval type is parsed by pg library as object {minutes: 10} not string
| Commit | Resolution |
|---|---|
c6e6a43b0 (prior) |
oauth.ts adds parseIntervalToMs for OAuth state cookie |
496ed7a01 |
cookie.ts adds parseIntervalToSeconds for session cookie |
Issue 5: encryptedSecrets loader using wrong module table
Problem: encryptedSecretsLoader was querying encrypted_secrets_module which doesn't exist; the generator creates app_secrets as part of config_secrets_user_module
| Commit | Resolution |
|---|---|
317f9c42f |
Initial fix attempted config_secrets_org_module (incorrect - that's for org_secrets) |
f0b1be4f8 |
Final fix: Use config_secrets_user_module + hardcode tableName: 'app_secrets' |
-- Final SQL in encryptedSecretsLoader
SELECT s.schema_name
FROM metaschema_modules_public.config_secrets_user_module csm
JOIN metaschema_public.schema s ON s.id = csm.schema_id
WHERE csm.database_id = $1Why hardcode app_secrets: The generator always creates user_secrets and app_secrets in the same schema from config_secrets_user_module. The table name is deterministic.
Issue 6: client_secret lookup via non-existent function
Problem: getIdentityProvider called schema.get() function that doesn't exist
| Commit | Resolution |
|---|---|
2de79a9b1 |
Use JOIN to fetch client_secret from app_secrets table directly |
-- JOIN to get decrypted secret
SELECT
ip.slug, ip.kind, ip.display_name, ip.enabled, ip.client_id,
convert_from(secrets.value, 'UTF8') as client_secret,
ip.authorization_url, ip.token_url, ip.userinfo_url, ip.scopes, ip.pkce_enabled
FROM "${privateSchemaName}"."${tableName}" ip
LEFT JOIN "${encryptedSchema}"."${encryptedTableName}" secrets
ON secrets.id = ip.client_secret_id
WHERE ip.slug = $1 AND ip.enabled = trueCommit Summary
| Commit | Description | Issues |
|---|---|---|
f0b1be4f8 |
Fix encryptedSecrets to use config_secrets_user_module | #5 |
2de79a9b1 |
Use JOIN to fetch client_secret | #6 |
b450bd085 |
Identity pre-check via connectedAccounts query | #2 |
496ed7a01 |
Handle PgInterval in session cookie config | #4 |
c310328e0 |
Add connectedAccounts loader | #1, #2 |
ebe0dafd7 |
Add sessionCredentialsSchemaName to userAuth | #1, #3 |
317f9c42f |
- | |
c6e6a43b0 |
Integrate DB settings for OAuth state cookie | #4 |
589b6adde |
Original OAuth middleware (pyramation) | - |
Reviewer Comments → Solutions Mapping
This section maps specific reviewer feedback from PR #1141 and #1220 to our implementation commits.
From devin-ai-integration (PR #1141 final comment):
Key changes in #1220:
sign_in_identity/sign_up_identitycalled inuserAuth.schemaName— not assumed to be in the RLSprivateSchema
Our solution:
| Commit | Resolution |
|---|---|
ebe0dafd7 |
userAuthLoader dynamically resolves sessionCredentialsSchemaName via JOIN |
b450bd085 |
oauth.ts uses identityProviders.privateSchemaName instead of hardcoded schema |
From devin-ai-integration (PR #1141 "DB Settings Available"):
Replace hardcoded constants:
OAUTH_STATE_MAX_AGE = 10 * 60 * 1000→oauth_state_max_agerequireVerifiedEmail ?? true→oauth_require_verified_emailerrorRedirectPath ?? '/auth/error'→oauth_error_redirect_path
Our solution:
| Commit | Resolution |
|---|---|
c6e6a43b0 |
Read oauthStateMaxAge, oauthRequireVerifiedEmail, oauthErrorRedirectPath from authSettings |
496ed7a01 |
cookie.ts handles PgInterval type from PostgreSQL |
From pyramation review:
"Should not use privateSchema assumption, use loaders instead"
"Should not catch IDENTITY_ACCOUNT_NOT_FOUND, query identity existence first"
Our solution:
| Issue | Commit | Resolution |
|---|---|---|
| privateSchema assumption | c310328e0, b450bd085 |
New connectedAccountsLoader, use identityProviders.privateSchemaName |
| catch exception pattern | b450bd085 |
Pre-query connected_accounts table via ctx.pool.query() |
From PR #1220 Review Checklist:
session_credentialsschema cannot be assumed to be inuserAuth.schemaName
Our solution:
| Commit | Resolution |
|---|---|
ebe0dafd7 |
userAuthLoader JOINs session_credentials_table_id to find correct schema, falls back to schema_name if null |
Architecture: Loaders vs Runtime Queries
Why the identity check is NOT in a loader:
Loaders (configuration queries):
├── Input: databaseId (fixed per tenant)
├── Output: schema/table names (configuration)
├── Caching: ✅ 5-minute TTL
└── Same result for all requests to same database
Identity Check (business logic query):
├── Input: provider + providerId (varies per request)
├── Output: boolean (exists or not)
├── Caching: ❌ Must be real-time
└── Different params for every OAuth callback
The loader provides the configuration (which schema/table to query), and the middleware executes the business logic (does this specific identity exist).
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
…et function The code was calling a schema.get(uuid) function that doesn't exist. Changed to JOIN the app_secrets table directly to fetch the secret value. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…lookup The encryptedSecretsLoader was incorrectly using config_secrets_org_module which tracks org_secrets table. The app_secrets table is created by the config_secrets_user_module generator in the same schema as user_secrets. Changed to query config_secrets_user_module and hardcode tableName to 'app_secrets' since both tables share the same schema. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| return result.rows.map((row: { slug: string }) => row.slug); | ||
| } | ||
|
|
||
| async function getIdentityProvider( |
There was a problem hiding this comment.
this should be made into one of those getModule() calls, no?
Review SummaryThe initial commit (589b6ad by @pyramation) established a clean module loader pattern. The subsequent commits by @theothersideofgod introduce several issues that break those patterns. Details in inline comments below. Critical issues:
|
| const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes | ||
| const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error'; | ||
|
|
||
| interface PgInterval { |
There was a problem hiding this comment.
PgInterval is already defined in @constructive-io/express-context types (and exported). This is a duplicate definition — should import from the shared types instead of redefining it locally.
| * Parse PostgreSQL interval to milliseconds. | ||
| * Handles: pg library object {minutes: 10}, string '10 minutes', '00:10:00' | ||
| */ | ||
| function parseIntervalToMs( |
There was a problem hiding this comment.
This parseIntervalToMs is near-identical to parseIntervalToSeconds in cookie.ts. Should be a single shared utility rather than duplicated parsing logic in two files.
|
|
||
| const userAgent = req.get('user-agent') || ''; | ||
| const { identityProviders, connectedAccounts } = modules; | ||
| const authPrivateSchema = identityProviders.privateSchemaName; |
There was a problem hiding this comment.
This replaces userAuth.schemaName with identityProviders.privateSchemaName for calling sign_in_identity / sign_up_identity. The original code deliberately used userAuth.schemaName because the whole point of the userAuthLoader is to resolve exactly which schema the auth functions live in. Using identityProviders.privateSchemaName defeats the purpose of having a separate userAuthLoader — it goes back to assuming these functions live in a schema determined by an unrelated module.
| ip.display_name, | ||
| ip.enabled, | ||
| ip.client_id, | ||
| convert_from(secrets.value, 'UTF8') as client_secret, |
There was a problem hiding this comment.
Two issues here:
1. Wrong table — app_secrets vs user_secrets
The encryptedSecrets loader was changed to hardcode tableName: 'app_secrets', but identity_providers.client_secret_id is a UUID pointer into user_secrets, not app_secrets. The rotate_identity_provider_secret procedure writes to user_secrets (with owner_id = provider_id, name = 'oauth_client_secret', algo = 'pgp'). app_secrets is a different table with different columns (key_id, namespace_id) for admin-level secrets.
2. convert_from(secrets.value, 'UTF8') bypasses PGP decryption
OAuth client secrets are stored with algo = 'pgp' — the BEFORE INSERT trigger PGP-encrypts the value using owner_id as the symmetric key. Raw convert_from will return encrypted garbage.
The correct approach is to call the generated user_secrets_get function which handles decryption based on the algo field:
"${schemaName}".user_secrets_get('oauth_client_secret', NULL, ip.id) as client_secretThis calls pgp_sym_decrypt(value, owner_id::text) internally for PGP-encrypted rows.
|
|
||
| // Check if identity exists using ctx.pool (bypasses RLS) | ||
| let identityExists = false; | ||
| if (connectedAccounts) { |
There was a problem hiding this comment.
This queries connected_accounts via ctx.pool.query — bypasses RLS entirely. The original pattern kept the entire sign-in/sign-up flow inside withPgClient which applies pgSettings via SET LOCAL.
| async (client) => { | ||
| // Set OAuth-specific JWT claims on this transaction | ||
| await client.query( | ||
| `SELECT set_config('jwt.claims.user_agent', $1, true), |
There was a problem hiding this comment.
withPgClient already applies pgSettings (role, claims, request_id) via SET LOCAL. These manual set_config('jwt.claims.*') calls should go through the existing pgSettings infrastructure rather than manually setting GUC variables inside the transaction.
|
|
||
| const userAgent = req.get('user-agent') || ''; | ||
| const { identityProviders, connectedAccounts } = modules; | ||
| const authPrivateSchema = identityProviders.privateSchemaName; |
There was a problem hiding this comment.
This replaces userAuth.schemaName with identityProviders.privateSchemaName for calling sign_in_identity / sign_up_identity. The original code deliberately used userAuth.schemaName because the whole point of the userAuthLoader is to resolve which schema the auth functions live in. Using identityProviders.privateSchemaName defeats the purpose of having a separate userAuthLoader.
| accessToken: string, | ||
| ): Promise<string> { | ||
| const otToken = crypto.randomBytes(32).toString('base64url'); | ||
| const { schemaName } = modules.userAuth; |
There was a problem hiding this comment.
Inconsistency: generateCrossOriginToken still uses modules.userAuth.schemaName for session_credentials, but sign_in/sign_up were switched to identityProviders.privateSchemaName. If the schema resolution needed to change, it should be consistent.
|
|
||
| const row = result.rows[0]; | ||
| if (!row) return undefined; | ||
|
|
There was a problem hiding this comment.
Switching to config_secrets_user_module is correct (per Dan's guidance), but two issues:
-
Hardcoded
'app_secrets'— breaks the dynamic resolution pattern. Theconfig_secrets_user_moduletable already hastable_id— should resolve the table name via thetable_idJOIN like the other loaders do, rather than hardcoding. -
Wrong table for OAuth —
identity_providers.client_secret_idpoints touser_secrets(the table registered inconfig_secrets_user_module.table_id), NOTapp_secrets. Therotate_identity_provider_secretprocedure writes touser_secretswithowner_id = provider_id.app_secretsis a different table withkey_id/namespace_idcolumns for admin-level application secrets.
The loader should resolve the user_secrets table (which is what config_secrets_user_module.table_id points to), and the OAuth middleware should call the generated user_secrets_get('oauth_client_secret', NULL, ip.id) function to decrypt.
identity_providers.client_secret_id points to user_secrets table, not app_secrets. The rotate_identity_provider_secret procedure writes to user_secrets with owner_id=provider_id. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Loads all enabled identity providers with decrypted client_secret into a Map for O(1) lookup. Combines data from identity_providers and user_secrets tables. Cached with 5min TTL. This replaces per-request getIdentityProvider queries in oauth.ts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove local PgInterval definition, import from express-context - Remove parseIntervalToMs, use exported parseIntervalToSeconds * 1000 - Remove getIdentityProvider/getEnabledProviders, use cached loader - Fix sessionCredentialsSchemaName (was using wrong schema) - Remove connectedAccounts pre-check, use exception-based flow (try sign_in_identity, catch IDENTITY_ACCOUNT_NOT_FOUND, then sign_up) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fix SummaryThanks for the review! Here's what was addressed: ✅ Fixed
💬 Not Changed (With Reason)
Please review the new commits. Let me know if there are any issues! |
Summary
Refactors the OAuth/SSO middleware from PR #1141 to use the module loader architecture from
express-context, addressing the review issues Dan identified.What changed
New module loaders (
packages/express-context/src/loaders/):encryptedSecretsLoader— resolvesencrypted_secrets_modulefrommetaschema_modules_publicto find the correct schema for decrypting secrets (replaces hardcoded metaschema JOIN)userAuthLoader— resolvesuser_auth_modulefor sign_in/sign_up function names and the schema they live in (no longer assumesprivateSchema)identityProvidersLoader— resolvesidentity_providers_modulefor provider config table location (schema + table name, not hardcoded)All three are registered in
createDefaultRegistry()and added toBuiltinModuleMapfor typeduseModule()access.OAuth middleware (
graphql/server/src/middleware/oauth.ts):req.constructive.useModule()to resolve all per-database config (identity providers, encrypted secrets, user auth, auth settings) — no manual metaschema queriesreq.constructive.withPgClient()for RLS-scoped transactions with automatic pgSettings — replaces manualset_config('jwt.claims.*', ..., false)callsexpress-rate-limit— DB already handles rate limiting viaauth_rate_limits/app_settings_rate_limitgetNodeEnv()from@pgpmjs/envinstead ofprocess.env.NODE_ENVsign_in_identity/sign_up_identityare called in theuserAuth.schemaName— not assumed to be in the RLSprivateSchemaemail_verifiedfrom the raw provider profile data (OAuthProfile doesn't expose it directly)Server wiring (
graphql/server/src/server.ts):createDefaultRegistry()asloaderstocreateContextMiddleware()— enablesuseModule()onreq.constructive/authIssues addressed from #1141 review
getEncryptedSecretsSchemauses fragile metaschema JOIN withprivateSchemaencryptedSecretsLoaderqueryingencrypted_secrets_modulebydatabase_idprocess.env.NODE_ENVused directlygetNodeEnv()from@pgpmjs/envexpress-rate-limitis per-process, redundant with DB rate limitingset_config('jwt.claims.*')withfalse(session-level)withPgClient()which applies pgSettings viaSET LOCAL(transaction-scoped)sign_in_identity/sign_up_identitylive inprivateSchemauserAuthLoaderwhich finds the actual schema fromuser_auth_moduleOAUTH_STATE_MAX_AGE,requireVerifiedEmail,errorRedirectPathemail_verifiedextracted from raw profileReview & Testing Checklist for Human
This is a medium-risk PR since OAuth is a security-sensitive flow and the module loaders are querying real module tables that must exist in the tenant database.
encryptedSecretsLoader,userAuthLoader, andidentityProvidersLoadermatches the actualmetaschema_modules_publictable schemas in the deployed databases (column names, JOINs, WHERE clauses)identity_providers_modulehas aprivate_schema_idcolumn that JOINs tometaschema_public.schema— the loader assumes thiswithPgClient()correctly scopes thejwt.claims.user_agentandjwt.claims.originsettings to the transaction (thetrueflag inset_configmeans transaction-local)createDefaultRegistry()is only called once per server instance (it's called in the constructor, so it should be fine)Notes
OAuthProfiletype from@constructive-io/oauthdoesn't includeemailVerified— we extract it fromprofile.rawwhich contains the original provider response. A follow-up could addemailVerifiedto theOAuthProfiletype.session_credentialstable used ingenerateCrossOriginTokenis accessed viauserAuth.schemaName— this assumes it lives in the same schema assign_in_identity. Worth verifying.Link to Devin session: https://app.devin.ai/sessions/0796902ebeba4f6ea1900d84deab2fc5
Requested by: @pyramation