Skip to content
Merged
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
3dea114
feat(auth): add isTeamCallback helper for unified login routing
mortondev Jun 20, 2026
92f4c18
feat(auth): surface admin login error codes on the unified login
mortondev Jun 20, 2026
f45081d
feat(auth): /auth/login serves team break-glass form for admin callbacks
mortondev Jun 20, 2026
a729aed
feat(auth): redirect /admin/login into the unified portal login
mortondev Jun 20, 2026
bb63246
feat(auth): repoint team-only auth redirects to the unified login
mortondev Jun 20, 2026
c280bcf
feat(auth): keep unified login + admin redirect onboarding-exempt
mortondev Jun 20, 2026
a98ddce
test(auth): e2e for unified login + team break-glass
mortondev Jun 20, 2026
2cb0c9e
test(auth): make break-glass e2e (case d) actually disable portal met…
mortondev Jun 20, 2026
e35ff50
feat(auth): add identity_provider table + domain provider_id
mortondev Jun 20, 2026
cfe3026
feat(auth): backfill identity providers in place from ssoOidc + custo…
mortondev Jun 20, 2026
b9f56bf
feat(auth): identity-provider repository/service
mortondev Jun 20, 2026
d6a8f89
feat(auth): register genericOAuth + portal providers dynamically from…
mortondev Jun 20, 2026
324b1c5
feat(auth): generalize provider dispatch from sso to the provider reg…
mortondev Jun 20, 2026
6469752
feat(auth): per-provider domain routing, enforcement, and provisioning
mortondev Jun 20, 2026
db05408
feat(auth): render portal provider buttons from the identity-provider…
mortondev Jun 20, 2026
e3f5412
feat(auth): admin server functions for identity-provider CRUD
mortondev Jun 20, 2026
0e02314
feat(auth): identity-provider list + editor UI
mortondev Jun 20, 2026
5d6bfca
fix(auth): don't show a misleading Test button for untested identity …
mortondev Jun 20, 2026
3b787ca
feat(auth): collapse Security to 2 tabs; nest 2FA under Password
mortondev Jun 20, 2026
fbed06e
feat(auth): retire /sso page; recovery codes move into Sign-in tab
mortondev Jun 20, 2026
c6e64d3
feat(auth): config-file declares identity providers as a list
mortondev Jun 20, 2026
f093555
fix(auth): stop misclassifying generic auth.* OIDC issuers as Okta
mortondev Jun 20, 2026
ec5366f
feat(auth): integration sweep + deferred legacy ssoOidc/custom-oidc c…
mortondev Jun 20, 2026
9caca04
fix(auth): close branch-review findings (portal button-only lockout, …
mortondev Jun 21, 2026
560724b
refactor(auth): scope onboarding ssoEnabled to the 'sso' provider; pa…
mortondev Jun 21, 2026
f44b3d3
fix(auth): close code-review findings (routed+showButton account-wipe…
mortondev Jun 21, 2026
04b2471
feat(auth): thread callbackUrl through the auth dialog + add recovery…
mortondev Jun 21, 2026
1494697
fix(auth): show recovery link in SSO-only Stage 1 (break-glass gap)
mortondev Jun 21, 2026
512c8ef
feat(auth): auto-open the portal sign-in dialog from a ?signin redirect
mortondev Jun 21, 2026
00a5ef0
feat(auth): private-portal gate honors callbackUrl + auto-opens the u…
mortondev Jun 21, 2026
3ff35a3
fix(auth): harden callbackUrl validation against backslash open-redirect
mortondev Jun 21, 2026
10c0607
feat(auth): instant-SSO redirect for single-provider SSO-only workspaces
mortondev Jun 21, 2026
bc0865e
refactor(auth): move use-auto-open-auth into components/ to satisfy l…
mortondev Jun 21, 2026
7a0ddf8
refactor(auth): repoint all /auth/login redirects to the portal sign-…
mortondev Jun 21, 2026
1ca89fc
refactor(auth): retire /auth/login + /auth/signup pages; delete team/…
mortondev Jun 21, 2026
a2802d7
feat(auth): require recovery codes before SSO enforcement can lock ou…
mortondev Jun 21, 2026
fa9a52f
feat(auth): add Admin entry to the portal user dropdown for team members
mortondev Jun 21, 2026
6bac03d
test(auth): e2e journeys for the unified sign-in dialog
mortondev Jun 21, 2026
7dd2f65
test(auth): complete private-portal gate sign-in journey + restore no…
mortondev Jun 21, 2026
b10dfb2
fix(auth): guard gate auto-open to unauthenticated + test instant-SSO…
mortondev Jun 21, 2026
f628a7f
fix(auth): single post-login navigation + correct 2FA callbackUrl + s…
mortondev Jun 22, 2026
7db9e56
fix(auth): surface blocked sign-ins + harden instant-SSO (single-OIDC…
mortondev Jun 22, 2026
bb78d7a
refactor(auth): unify auth-prompt parsing + dedupe redirect helpers +…
mortondev Jun 22, 2026
1091d34
refactor(auth): use auth=signin/auth=signup search param (cleaner URL…
mortondev Jun 22, 2026
c221add
fix(auth): scope the recovery-code link to the SSO views only
mortondev Jun 22, 2026
486abce
fix(auth): full-navigate to /admin after sign-in to avoid a blank tra…
mortondev Jun 22, 2026
9e0a53d
feat(auth): per-provider SSO test sign-in
mortondev Jun 22, 2026
05173ea
feat(auth): unified sign-in hardening, IdP kind persistence, portal f…
mortondev Jun 23, 2026
14715d5
refactor(auth): remove dead code surfaced by the unified-auth migration
mortondev Jun 23, 2026
1120143
refactor(auth): regroup the Security sign-in settings and refresh copy
mortondev Jun 23, 2026
8ca10d9
fix(e2e): stop logging the portal oauth config in the auth-methods he…
mortondev Jun 23, 2026
e82001f
feat(auth): add unified sign-in-method default helper
mortondev Jun 23, 2026
907325b
feat(auth): backfill unified sign-in methods at startup
mortondev Jun 23, 2026
44923aa
refactor(auth): gate every role on authConfig.oauth
mortondev Jun 23, 2026
d230df9
refactor(auth): drop surface from lookupAuthMethodsFn
mortondev Jun 23, 2026
ccfd2f3
fix(auth): instant-SSO reads the unified sign-in config
mortondev Jun 23, 2026
8050ce3
refactor(auth): serve sign-in methods from publicAuthConfig only
mortondev Jun 23, 2026
72c5f0e
refactor(auth): write sign-in methods to authConfig only
mortondev Jun 23, 2026
0cdda7c
refactor(auth): retire portalConfig.oauth; single-config provider reg…
mortondev Jun 23, 2026
d1329f1
fix(auth): lockout backstop honors the opt-in magic-link default
mortondev Jun 23, 2026
ff99993
fix(auth): propagate the sign-in-methods backfill (cache + auth-version)
mortondev Jun 23, 2026
69aec1f
fix(auth): preserve current path for 2FA redirect when dialog opens w…
mortondev Jun 23, 2026
7268693
fix(auth): reset auth after the custom-OIDC startup backfill inserts …
mortondev Jun 23, 2026
066077f
fix(auth): SSRF-guard the IdP discoveryUrl before write
mortondev Jun 23, 2026
24c2f4b
fix(auth): restamp detailsChangedAt when IdP connection fields change
mortondev Jun 23, 2026
0cb4a42
fix(auth): guard managed IdP paths in upsertIdentityProviderFn
mortondev Jun 23, 2026
5492ba7
refactor(config-file): configure OIDC providers via in-app UI only
mortondev Jun 23, 2026
4186419
refactor(config-file): manage only workspace + tierLimits (drop auth …
mortondev Jun 23, 2026
e749d53
fix(auth): address unified-auth migration-review follow-ups
mortondev Jun 24, 2026
96fbac5
refactor(portal): remove transitional "Signing in…" header state
mortondev Jun 24, 2026
62c7c82
feat(auth): expose twoFactor.required to the auth dialog
mortondev Jun 24, 2026
5a12e1d
refactor(auth): drop server 2FA sign-in gates; enrollment moves to th…
mortondev Jun 24, 2026
b939778
refactor(auth): drop server 2FA sign-in gates; enrollment moves to th…
mortondev Jun 24, 2026
fc99a87
refactor(auth): extract shared TwoFactorEnrollSteps component
mortondev Jun 24, 2026
f85c275
feat(auth): inline 2FA challenge + enrollment in the auth dialog
mortondev Jun 24, 2026
6a60434
refactor(auth): remove /auth/two-factor* pages and dead 2FA redirect …
mortondev Jun 24, 2026
57d509e
fix(auth): correct 2FA docs + resilient dialog cancel (review follow-…
mortondev Jun 24, 2026
26f2277
fix(auth): close 4 Codex SSO/credential review items
mortondev Jun 24, 2026
396fecf
feat(auth): support testing manual-endpoint OIDC providers
mortondev Jun 24, 2026
a7d09cd
fix(auth): guard loader location + align sso-test-callback expectation
mortondev Jun 24, 2026
844d180
fix(auth): close 3 follow-up Codex review items on the IdP changes
mortondev Jun 24, 2026
c199c08
fix(auth): revoke session on 2FA abandon + count default-on password
mortondev Jun 24, 2026
120650c
fix(auth): stop flaky "window is not defined" in 2FA enroll tests
mortondev Jun 24, 2026
63b35e2
refactor(auth): remove dead code and dedupe in unified-auth
mortondev Jun 24, 2026
7e30596
feat(portal): inline the shared auth form on the private-portal gate
mortondev Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/web/e2e/scripts/refresh-admin-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* CLI: refresh e2e/.auth/admin.json with a current session token for
* demo@example.com. Uses the most recent valid session row directly from the
* DB and constructs the signed cookie (HMAC-SHA256(token, SECRET_KEY)) so the
* stored auth-state works without navigating through the magic-link flow.
*
* Run this instead of global-setup when the admin session has expired and
* magic-link sign-in is blocked by twoFactor.required (or any other 2FA gate).
*
* Usage: bun refresh-admin-session.ts
*/
import postgres from 'postgres'
import { createHmac } from 'crypto'
import { writeFileSync } from 'fs'
import { resolve } from 'path'

const connectionString = process.env.DATABASE_URL
if (!connectionString) {
console.error('DATABASE_URL environment variable is required')
process.exit(1)
}
const secret = process.env.SECRET_KEY
if (!secret) {
console.error('SECRET_KEY environment variable is required')
process.exit(1)
}

const sql = postgres(connectionString)

try {
// Get the most recent valid session for the admin user.
const rows = await sql`
SELECT s.token, s.expires_at
FROM session s
JOIN "user" u ON u.id = s.user_id
WHERE u.email = 'demo@example.com'
AND s.expires_at > NOW()
ORDER BY s.created_at DESC
LIMIT 1
`

if (rows.length === 0) {
console.error(
'No valid session found for demo@example.com. ' +
'Run the global-setup project (bun run test:e2e --project=setup) first.'
)
await sql.end()
process.exit(1)
}

const token = rows[0].token as string
const expiresAt = rows[0].expires_at as Date

// Reproduce better-auth's setSignedCookie format: token.HMAC-SHA256(token, secret)
const sig = createHmac('sha256', secret).update(token).digest('base64')
const cookieValue = `${token}.${sig}`

// Write the storageState JSON that Playwright understands.
const state = {
cookies: [
{
name: '__Secure-better-auth.session_token',
value: cookieValue,
domain: 'acme.localhost',
path: '/',
expires: expiresAt.getTime() / 1000,
httpOnly: true,
secure: true,
sameSite: 'Lax' as const,
},
],
origins: [],
}

const outPath = resolve(import.meta.dirname, '../.auth/admin.json')
writeFileSync(outPath, JSON.stringify(state, null, 2))
console.log(
JSON.stringify({ action: 'refresh-admin-session', token: token.slice(0, 8) + '…', expiresAt })
)
await sql.end()
} catch (err) {
console.error(err instanceof Error ? err.message : String(err))
await sql.end()
process.exit(1)
}
133 changes: 133 additions & 0 deletions apps/web/e2e/scripts/seed-identity-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* CLI: seed or remove an `identity_provider` row (+ its encrypted client
* credential and an optional verified domain) for the identity-providers e2e.
*
* settings.* columns are JSON text; the provider model lives in the
* `identity_provider`, `sso_verified_domain`, and `integration_platform_credentials`
* tables. We write raw SQL (same style as the other e2e scripts) and generate
* TypeIDs via `@quackback/ids` (the column default is a JS-level drizzle default
* that a raw insert wouldn't trigger).
*
* The client secret is encrypted with the SAME AES-256-GCM + HKDF scheme as the
* app's `encryptPlatformCredentials` (purpose `integration-platform-credentials`)
* and stored at integration_type `auth_<registrationId>`, so the provider
* satisfies the runtime registration gate (enabled + creds + tier). The
* registration/routing/button gates only check that the credential ROW exists
* (no decrypt), so the secret never needs to round-trip — but encrypting it
* properly keeps the row indistinguishable from an app-written one.
*
* Usage:
* bun seed-identity-provider.ts seed '<json>'
* bun seed-identity-provider.ts remove <registrationId>
*
* seed JSON: {
* registrationId, label, clientId,
* discoveryUrl?, enabled?=true, showButton?=false, clientSecret?='e2e-secret',
* domain?: { name, verified?=true, enforced?=false }
* }
*
* After mutating, the caller (access-helpers) drops the tenant-settings +
* configured-types Redis caches so the running dev server sees the change.
*/
import postgres from 'postgres'
import { hkdfSync, randomBytes, createCipheriv, randomUUID } from 'crypto'
import { generateId, toUuid } from '@quackback/ids'

const action = (process.argv[2] || '').toLowerCase()
if (action !== 'seed' && action !== 'remove') {
console.error("Usage: bun seed-identity-provider.ts <seed '<json>' | remove <registrationId>>")
process.exit(1)
}

const connectionString = process.env.DATABASE_URL
if (!connectionString) {
console.error('DATABASE_URL environment variable is required')
process.exit(1)
}

/** Mirror of `encryptPlatformCredentials` (lib/server/integrations/encryption.ts):
* HKDF-SHA256(SECRET_KEY, salt, info) -> AES-256-GCM, format iv.tag.ct (base64url). */
function encryptPlatformCredentials(creds: Record<string, string>): string {
const secretKey = process.env.SECRET_KEY
if (!secretKey) throw new Error('SECRET_KEY environment variable is required')
const info = 'quackback:v1:integration-platform-credentials'
const key = Buffer.from(hkdfSync('sha256', secretKey, 'quackback-encryption-salt-v1', info, 32))
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 })
const ct = Buffer.concat([cipher.update(JSON.stringify(creds), 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return [iv.toString('base64url'), tag.toString('base64url'), ct.toString('base64url')].join('.')
}

interface SeedConfig {
registrationId: string
label: string
clientId: string
discoveryUrl?: string
enabled?: boolean
showButton?: boolean
clientSecret?: string
domain?: { name: string; verified?: boolean; enforced?: boolean }
}

const sql = postgres(connectionString)

async function remove(registrationId: string): Promise<void> {
// Deleting the provider cascades its sso_verified_domain rows (FK on delete
// cascade); the credential has no FK, so drop it explicitly.
await sql`DELETE FROM identity_provider WHERE registration_id = ${registrationId}`
await sql`DELETE FROM integration_platform_credentials WHERE integration_type = ${`auth_${registrationId}`}`
}

async function seed(cfg: SeedConfig): Promise<void> {
// Idempotent: clear any prior row for this registrationId first.
await remove(cfg.registrationId)

const idpUuid = toUuid(generateId('identity_provider'))
await sql`
INSERT INTO identity_provider
(id, registration_id, label, client_id, discovery_url, enabled, auto_create_users, show_button, created_at)
VALUES
(${idpUuid}, ${cfg.registrationId}, ${cfg.label}, ${cfg.clientId}, ${cfg.discoveryUrl ?? null},
${cfg.enabled ?? true}, true, ${cfg.showButton ?? false}, NOW())`

const credUuid = toUuid(generateId('platform_cred'))
const secrets = encryptPlatformCredentials({ clientSecret: cfg.clientSecret ?? 'e2e-secret' })
await sql`
INSERT INTO integration_platform_credentials (id, integration_type, secrets, created_at, updated_at)
VALUES (${credUuid}, ${`auth_${cfg.registrationId}`}, ${secrets}, NOW(), NOW())`

if (cfg.domain) {
const domUuid = toUuid(generateId('domain'))
const verifiedAt = cfg.domain.verified === false ? null : new Date()
await sql`
INSERT INTO sso_verified_domain
(id, name, verification_token, verified_at, enforced, provider_id, created_at)
VALUES
(${domUuid}, ${cfg.domain.name}, ${`e2e-${randomUUID()}`}, ${verifiedAt},
${cfg.domain.enforced ?? false}, ${idpUuid}, NOW())`
}
}

try {
if (action === 'remove') {
const registrationId = process.argv[3]
if (!registrationId) throw new Error('remove requires a <registrationId>')
await remove(registrationId)
console.log(JSON.stringify({ action: 'remove', registrationId }))
} else {
const raw = process.argv[3]
if (!raw) throw new Error("seed requires a '<json>' config")
const cfg = JSON.parse(raw) as SeedConfig
if (!cfg.registrationId || !cfg.label || !cfg.clientId) {
throw new Error('seed config requires registrationId, label, clientId')
}
await seed(cfg)
console.log(JSON.stringify({ action: 'seed', registrationId: cfg.registrationId }))
}
await sql.end()
} catch (err) {
console.error(err instanceof Error ? err.message : String(err))
await sql.end()
process.exit(1)
}
77 changes: 77 additions & 0 deletions apps/web/e2e/scripts/set-portal-auth-methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* CLI: disable or restore portal public auth methods in settings.portal_config.
* settings.portal_config is a JSON *text* column, so we read → patch → write.
* There is a single workspace settings row.
*
* When disabling: all stored oauth keys plus the known core methods (password,
* magicLink) are set to false — no portal sign-in method is presented to
* public users. The team break-glass form (TeamLoginForm) still appears for
* team-bound callbackUrls; that is the invariant this helper enables testing.
*
* When restoring: oauth is reset to the default portal config values
* (mirrors DEFAULT_PORTAL_CONFIG.oauth — password + standard OAuth on,
* magicLink off).
*
* Usage: bun set-portal-auth-methods.ts <disable|restore>
*/
import postgres from 'postgres'

const arg = (process.argv[2] || '').toLowerCase()
if (arg !== 'disable' && arg !== 'restore' && arg !== 'enable-magic-link') {
console.error('Usage: bun set-portal-auth-methods.ts <disable|restore|enable-magic-link>')
process.exit(1)
}

const connectionString = process.env.DATABASE_URL
if (!connectionString) {
console.error('DATABASE_URL environment variable is required')
process.exit(1)
}
const sql = postgres(connectionString)

try {
const rows = await sql`SELECT id, portal_config FROM settings ORDER BY created_at ASC LIMIT 1`
if (rows.length === 0) throw new Error('No settings row found')
const id = rows[0].id
let config: Record<string, unknown> = {}
if (rows[0].portal_config) {
try {
config = JSON.parse(rows[0].portal_config as string)
} catch {
config = {}
}
}

if (arg === 'disable') {
// Turn off every portal oauth method currently stored plus the core keys.
// Iterating existing keys handles any dynamic OAuth providers (custom-oidc, etc.)
// that may have been configured without this script knowing about them.
const existing = (config.oauth as Record<string, unknown>) ?? {}
const disabled: Record<string, unknown> = {}
for (const key of Object.keys(existing)) {
disabled[key] = false
}
// Ensure the canonical methods are explicitly disabled even if not yet stored.
disabled.password = false
disabled.magicLink = false
config.oauth = disabled
} else if (arg === 'enable-magic-link') {
// Enable only the magicLink method, leaving other settings untouched. Used
// by test setup that needs to sign in portal users (role='user') on repeat
// runs: the hooks check blocks magic-link for existing portal users when
// magicLink is off, so we open it just for the sign-in then restore.
const existing = (config.oauth as Record<string, unknown>) ?? {}
config.oauth = { ...existing, magicLink: true }
} else {
// Restore to the default portal oauth config (mirrors DEFAULT_PORTAL_CONFIG.oauth).
config.oauth = { password: true, email: false, google: true, github: true }
}

await sql`UPDATE settings SET portal_config = ${JSON.stringify(config)} WHERE id = ${id}`
console.log(JSON.stringify({ action: arg, oauth: config.oauth }))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
await sql.end()
} catch (err) {
console.error(err instanceof Error ? err.message : String(err))
await sql.end()
process.exit(1)
}
54 changes: 54 additions & 0 deletions apps/web/e2e/scripts/set-portal-visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* CLI: set portal visibility to 'public' or 'private' in settings.portal_config.
* settings.portal_config is a JSON *text* column, so we read → patch → write.
* There is a single workspace settings row.
*
* When set to 'private': unauthenticated visitors hit the PortalAccessGate
* before seeing any portal content. Used by e2e tests that verify the gate
* renders with the dialog auto-opened (journey 3).
*
* Always restore to 'public' in a `finally` block so subsequent tests and
* dev sessions are not left with a locked portal.
*
* Usage: bun set-portal-visibility.ts <private|public>
*/
import postgres from 'postgres'

const arg = (process.argv[2] || '').toLowerCase()
if (arg !== 'private' && arg !== 'public') {
console.error('Usage: bun set-portal-visibility.ts <private|public>')
process.exit(1)
}

const connectionString = process.env.DATABASE_URL
if (!connectionString) {
console.error('DATABASE_URL environment variable is required')
process.exit(1)
}
const sql = postgres(connectionString)

try {
const rows = await sql`SELECT id, portal_config FROM settings ORDER BY created_at ASC LIMIT 1`
if (rows.length === 0) throw new Error('No settings row found')
const id = rows[0].id
let config: Record<string, unknown> = {}
if (rows[0].portal_config) {
try {
config = JSON.parse(rows[0].portal_config as string)
} catch {
config = {}
}
}

// Merge the visibility into the access sub-object, preserving other keys.
const existingAccess = (config.access as Record<string, unknown>) ?? {}
config.access = { ...existingAccess, visibility: arg }

await sql`UPDATE settings SET portal_config = ${JSON.stringify(config)} WHERE id = ${id}`
console.log(JSON.stringify({ action: 'set-portal-visibility', visibility: arg }))
await sql.end()
} catch (err) {
console.error(err instanceof Error ? err.message : String(err))
await sql.end()
process.exit(1)
}
Loading