-
Notifications
You must be signed in to change notification settings - Fork 7
Add admin-managed OAuth sign-in flow #1303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Zetazzz
wants to merge
19
commits into
main
Choose a base branch
from
feat/oauth-reorg
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+4,891
−7,771
Open
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
1f70db1
oauth related express context chagnes
Zetazzz 97b9948
fixed ipm and types
Zetazzz 3d145db
refactor(express-context): clarify OAuth module loaders and follow-ups
Zetazzz 7e640d3
Add OAuth email verification handling and tests
Zetazzz 3f0e4a6
Migrate GraphQL OAuth routes and admin API integration
Zetazzz 77a2882
remove outdated oauth express middleware
Zetazzz 5d52c48
abstract functions of pg interval and signed state
Zetazzz 5f8b4de
remove cross origin handoff token logic
Zetazzz 5166367
Merge branch 'main' into feat/oauth-reorg
Zetazzz f77d301
apply loaders to app setting auth middleware
Zetazzz 1395ace
Revert "apply loaders to app setting auth middleware"
Zetazzz a813391
update followup
Zetazzz 604d7b3
Reapply "apply loaders to app setting auth middleware"
Zetazzz 2a2df1a
update oauth followup after app settings loader reapply
Zetazzz 1a3c511
handle app setting update in middleware
Zetazzz e129f14
regulate JWT claims usage
Zetazzz 288eed5
fix: update oauth lockfile
Zetazzz 349175a
merge main into oauth branch
Zetazzz 4f3136b
fix env snapshots
Zetazzz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # OAuth Implementation Follow-up | ||
|
|
||
| ## Identity Providers Module Scope | ||
|
|
||
| - Investigate the platform `identity_providers_module` record whose `scope` is currently `app`. For platform-level OAuth configuration, the expected scope appears to be `platform`; keeping it as `app` may be a provisioning or migration artifact and can make platform OAuth semantics ambiguous. | ||
|
|
||
| ## Identity Auth Function Metadata | ||
|
|
||
| - Consider making `sign_in_identity_function` and `sign_up_identity_function` metadata-driven. These functions are generated by the user auth module, but the current express-context config uses hardcoded function names. A future database/schema update could expose these function names through module metadata, similar to other auth functions. | ||
|
|
||
| ## Auth Settings Interval Parsing | ||
|
|
||
| - Fix cookie/session duration parsing after the OAuth loader work lands. `app_settings_auth.cookie_max_age`, `remember_me_duration`, and `oauth_state_max_age` are PostgreSQL `interval` columns; `pg` returns them as `PostgresInterval` objects such as `{ days: 14 }` or `{ minutes: 10 }`. The current GraphQL server cookie helper still parses these values as second strings with `parseInt`, so loader-backed auth settings can silently fall back to defaults. This is an existing cookie auth settings compatibility issue exposed by the OAuth/auth settings loader path and should be handled in a follow-up PR. | ||
|
|
||
| ## Loader Cache Invalidation and TTL Semantics | ||
|
|
||
| - Revisit `createModuleLoader` cache expiration and invalidation semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior should be decided together with explicit invalidation for writes performed by our own systems. | ||
|
|
||
| - Known changes made through this process, such as admin APIs updating auth settings or identity providers, should invalidate the relevant loader cache after the database write succeeds. The current auth settings update path invalidates `authSettingsLoader`, but identity provider admin writes do not yet invalidate the cached OAuth provider config. Recommended shape: loaders own read, transform, cache, and invalidate; services or repositories own validate, authorize, write, audit, and post-write invalidate. For example, `identityProvidersService.updateProvider(ctx, input)` writes the provider config and then calls a targeted invalidation such as `registry.invalidate(ctx.databaseId, "identityProviders")` or the equivalent loader-specific invalidation API. | ||
|
|
||
| - Unknown external changes, such as manual SQL, migrations, or another service updating module configuration, cannot be invalidated precisely by this process. Baseline approach: keep `updateAgeOnGet: false` so TTL remains a bounded staleness window, then reload from the database on the next read after TTL expiry. If stronger freshness is needed later, add an optional lightweight fingerprint probe near loader resolution, for example `getFingerprint(ctx)` using `updated_at`, version, or checksum plus `revalidateAfterMs` as the minimum interval between probes. This lets loaders detect external changes faster than full TTL expiry without fully reloading config on every request. | ||
|
|
||
| - Recommendation: set `updateAgeOnGet` to `false`, add explicit post-write invalidation for known admin writes, and rely on TTL as the fallback for unknown external changes. Add fingerprint probing only if TTL-based staleness becomes too slow in practice. | ||
|
|
||
| ## API Service Cache Loader Snapshots | ||
|
|
||
| - Revisit `graphql/server/src/middleware/api.ts` storing resolved loader values inside the cached `ApiStructure`. The API resolver currently resolves mutable module settings such as `authSettings`, `corsOrigins`, `databaseSettings`, `pubkeyChallengeSettings`, and `webauthnSettings`, then stores the whole API structure in `svcCache`, whose TTL is effectively long-lived. This can bypass each loader's own TTL and invalidation path; for example, `updateAuthSettings()` invalidates `authSettingsLoader`, but middleware that reads `req.api.authSettings` could still see the old value from `svcCache`. Consider narrowing `svcCache` to stable API routing fields only, resolving mutable module settings through `ctx.useModule(...)` at use sites, or adding coordinated `svcCache` invalidation whenever loader-backed settings are updated. | ||
|
|
||
| ## Env Config Consolidation | ||
|
|
||
| - Consider moving existing CAPTCHA and upload environment variables into the shared `@pgpmjs/env` config surface in a separate cleanup PR. The reference OAuth branch added `RECAPTCHA_SECRET_KEY` and `MAX_UPLOAD_FILE_SIZE` to `PgpmOptions`, but this OAuth migration keeps those existing middleware paths on direct `process.env` reads to avoid widening the PR scope beyond OAuth/server admin APIs. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,268 @@ | ||
| /** | ||
| * App Settings Auth API | ||
| * | ||
| * Express router for managing auth settings (cookie config, captcha, OAuth settings). | ||
| * Requires administrator role. Reads/writes to app_settings_auth table via | ||
| * the authSettings loader. | ||
| * | ||
| * Routes: | ||
| * GET /app-settings-auth → get current settings | ||
| * PATCH /app-settings-auth → update settings | ||
| */ | ||
|
|
||
| import express, { Router, Request, Response } from 'express'; | ||
| import { Logger } from '@pgpmjs/logger'; | ||
| import { QuoteUtils } from '@pgsql/quotes'; | ||
| import { | ||
| authSettingsLoader, | ||
| type AuthSettings, | ||
| type ConstructiveContext, | ||
| } from '@constructive-io/express-context'; | ||
|
|
||
| import './types'; | ||
|
|
||
| const log = new Logger('app-settings-auth'); | ||
|
|
||
| // ─── SQL ──────────────────────────────────────────────────────────────────── | ||
|
|
||
| const AUTH_SETTINGS_DISCOVERY_SQL = ` | ||
| SELECT s.schema_name, sm.auth_settings_table AS table_name | ||
| FROM metaschema_modules_public.sessions_module sm | ||
| JOIN metaschema_public.schema s ON s.id = sm.schema_id | ||
| WHERE sm.database_id = $1 | ||
| LIMIT 1 | ||
| `; | ||
|
|
||
| // ─── Types ────────────────────────────────────────────────────────────────── | ||
|
|
||
| interface AuthSettingsTableRef { | ||
| schemaName: string; | ||
| tableName: string; | ||
| } | ||
|
|
||
| interface UpdateAuthSettingsInput { | ||
| allowIdentitySignIn?: boolean; | ||
| allowIdentitySignUp?: boolean; | ||
| cookieSecure?: boolean; | ||
| cookieSamesite?: string; | ||
| cookieDomain?: string | null; | ||
| cookieHttponly?: boolean; | ||
| cookieMaxAge?: string | null; | ||
| cookiePath?: string; | ||
| rememberMeDuration?: string | null; | ||
| enableCaptcha?: boolean; | ||
| captchaSiteKey?: string | null; | ||
| oauthStateMaxAge?: string | null; | ||
| oauthRequireVerifiedEmail?: boolean; | ||
| oauthErrorRedirectPath?: string | null; | ||
| } | ||
|
|
||
| type UpdateAuthSettingsResult = | ||
| | 'updated' | ||
| | 'not_configured' | ||
| | 'no_fields'; | ||
|
|
||
| const UPDATE_FIELD_MAP: Record< | ||
| keyof UpdateAuthSettingsInput, | ||
| { column: string; castInterval?: boolean } | ||
| > = { | ||
| allowIdentitySignIn: { column: 'allow_identity_sign_in' }, | ||
| allowIdentitySignUp: { column: 'allow_identity_sign_up' }, | ||
| cookieSecure: { column: 'cookie_secure' }, | ||
| cookieSamesite: { column: 'cookie_samesite' }, | ||
| cookieDomain: { column: 'cookie_domain' }, | ||
| cookieHttponly: { column: 'cookie_httponly' }, | ||
| cookieMaxAge: { column: 'cookie_max_age', castInterval: true }, | ||
| cookiePath: { column: 'cookie_path' }, | ||
| rememberMeDuration: { column: 'remember_me_duration', castInterval: true }, | ||
| enableCaptcha: { column: 'enable_captcha' }, | ||
| captchaSiteKey: { column: 'captcha_site_key' }, | ||
| oauthStateMaxAge: { column: 'oauth_state_max_age', castInterval: true }, | ||
| oauthRequireVerifiedEmail: { column: 'oauth_require_verified_email' }, | ||
| oauthErrorRedirectPath: { column: 'oauth_error_redirect_path' }, | ||
| }; | ||
|
|
||
| // ─── Helpers ──────────────────────────────────────────────────────────────── | ||
|
|
||
| async function isAppMember(ctx: ConstructiveContext): Promise<boolean> { | ||
| const userId = ctx.userId; | ||
| if (!userId) return false; | ||
|
|
||
| const sql = ` | ||
| SELECT 1 FROM constructive_memberships_private.app_memberships_sprt | ||
| WHERE actor_id = $1 | ||
| LIMIT 1 | ||
| `; | ||
| const result = await ctx.pool.query(sql, [userId]); | ||
| return result.rows.length > 0; | ||
| } | ||
|
|
||
| async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise<boolean> { | ||
| if (!(await isAppMember(ctx))) { | ||
| res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| function sendAuthSettings(res: Response, settings: AuthSettings): void { | ||
| res.json({ | ||
| allowIdentitySignIn: settings.allowIdentitySignIn, | ||
| allowIdentitySignUp: settings.allowIdentitySignUp, | ||
| cookieSecure: settings.cookieSecure, | ||
| cookieSamesite: settings.cookieSamesite, | ||
| cookieDomain: settings.cookieDomain, | ||
| cookieHttponly: settings.cookieHttponly, | ||
| cookieMaxAge: settings.cookieMaxAge, | ||
| cookiePath: settings.cookiePath, | ||
| rememberMeDuration: settings.rememberMeDuration, | ||
| enableCaptcha: settings.enableCaptcha, | ||
| captchaSiteKey: settings.captchaSiteKey, | ||
| oauthStateMaxAge: settings.oauthStateMaxAge, | ||
| oauthRequireVerifiedEmail: settings.oauthRequireVerifiedEmail, | ||
| oauthErrorRedirectPath: settings.oauthErrorRedirectPath, | ||
| }); | ||
| } | ||
|
|
||
| async function discoverAuthSettingsTable( | ||
| ctx: ConstructiveContext, | ||
| ): Promise<AuthSettingsTableRef | null> { | ||
| if (!ctx.databaseId) return null; | ||
|
|
||
| const discovery = await ctx.pool.query<{ schema_name: string; table_name: string }>( | ||
| AUTH_SETTINGS_DISCOVERY_SQL, | ||
| [ctx.databaseId], | ||
| ); | ||
| const resolved = discovery.rows[0]; | ||
| if (!resolved) return null; | ||
|
|
||
| return { | ||
| schemaName: resolved.schema_name, | ||
| tableName: resolved.table_name, | ||
| }; | ||
| } | ||
|
|
||
| function buildUpdateAuthSettingsQuery( | ||
| schemaName: string, | ||
| tableName: string, | ||
| patch: UpdateAuthSettingsInput, | ||
| ): { sql: string; values: unknown[] } | null { | ||
| const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( | ||
| schemaName, | ||
| tableName, | ||
| ); | ||
| const setClauses: string[] = []; | ||
| const values: unknown[] = []; | ||
| let paramIndex = 1; | ||
|
|
||
| for (const [field, config] of Object.entries(UPDATE_FIELD_MAP) as Array< | ||
| [keyof UpdateAuthSettingsInput, { column: string; castInterval?: boolean }] | ||
| >) { | ||
| if (field in patch) { | ||
| const cast = config.castInterval ? '::interval' : ''; | ||
| setClauses.push( | ||
| `${QuoteUtils.quoteIdentifier(config.column)} = $${paramIndex++}${cast}`, | ||
| ); | ||
| values.push(patch[field]); | ||
| } | ||
| } | ||
|
|
||
| if (setClauses.length === 0) return null; | ||
|
|
||
| return { | ||
| sql: ` | ||
| UPDATE ${authSettingsTable} | ||
| SET ${setClauses.join(', ')} | ||
| `, | ||
| values, | ||
| }; | ||
| } | ||
|
|
||
| async function updateAuthSettings( | ||
| ctx: ConstructiveContext, | ||
| patch: UpdateAuthSettingsInput, | ||
| ): Promise<UpdateAuthSettingsResult> { | ||
| const table = await discoverAuthSettingsTable(ctx); | ||
| if (!table) return 'not_configured'; | ||
|
|
||
| const update = buildUpdateAuthSettingsQuery( | ||
| table.schemaName, | ||
| table.tableName, | ||
| patch, | ||
| ); | ||
| if (!update) return 'no_fields'; | ||
|
|
||
| await ctx.pool.query(update.sql, update.values); | ||
| if (ctx.databaseId) { | ||
| authSettingsLoader.invalidate(ctx.databaseId); | ||
| } | ||
|
|
||
| return 'updated'; | ||
| } | ||
|
|
||
| // ─── Router ───────────────────────────────────────────────────────────────── | ||
|
|
||
| export function createAppSettingsAuthRouter(): Router { | ||
| const router = Router(); | ||
|
|
||
| // Parse JSON body for PATCH requests | ||
| router.use(express.json()); | ||
|
|
||
| /** | ||
| * GET /app-settings-auth | ||
| * Get current auth settings | ||
| */ | ||
| router.get('/app-settings-auth', async (req: Request, res: Response) => { | ||
| const ctx = req.constructive; | ||
| if (!ctx) { | ||
| return res.status(500).json({ error: 'Missing context' }); | ||
| } | ||
|
|
||
| if (!(await requireAppMember(ctx, res))) return; | ||
|
|
||
| try { | ||
| const settings = await ctx.useModule('authSettings'); | ||
| if (!settings) { | ||
| return res.status(404).json({ error: 'Auth settings not found' }); | ||
| } | ||
|
|
||
| sendAuthSettings(res, settings); | ||
| } catch (error) { | ||
| log.error('[app-settings-auth] Failed to get settings:', error); | ||
| res.status(500).json({ error: 'Failed to get settings' }); | ||
| } | ||
| }); | ||
|
|
||
| /** | ||
| * PATCH /app-settings-auth | ||
| * Update auth settings | ||
| */ | ||
| router.patch('/app-settings-auth', async (req: Request, res: Response) => { | ||
| const ctx = req.constructive; | ||
| if (!ctx) { | ||
| return res.status(500).json({ error: 'Missing context' }); | ||
| } | ||
|
|
||
| if (!(await requireAppMember(ctx, res))) return; | ||
|
|
||
| const body = req.body as UpdateAuthSettingsInput; | ||
|
|
||
| try { | ||
| const result = await updateAuthSettings(ctx, body); | ||
| if (result === 'not_configured') { | ||
| return res.status(404).json({ error: 'Auth settings module not configured' }); | ||
| } | ||
| if (result === 'no_fields') { | ||
| return res.status(400).json({ error: 'No fields to update' }); | ||
| } | ||
|
|
||
| log.info('[app-settings-auth] Updated settings'); | ||
| res.json({ success: true }); | ||
| } catch (error) { | ||
| log.error('[app-settings-auth] Failed to update settings:', error); | ||
| res.status(500).json({ error: 'Failed to update settings' }); | ||
| } | ||
| }); | ||
|
|
||
| return router; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why are we making REST routes for things the user can query for via our APIs?