Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 20 additions & 67 deletions apps/web/src/lib/server/auth/__tests__/auth-restrictions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
* binding (SSO *is* the enforced method). OAuth-callback paths are
* gated in Layer C (`handleCallbackPolicyCleanup`); Layer B handles
* password / magic-link pre-session.
* - Master switch `ssoOidc.enabled=false` disables enforcement
* indirectly via `isSsoActuallyRegistered`.
* - Runtime fail-open: callers pass `ssoActuallyRegistered`; when
* false (tier downgrade, missing secret) the branch is dormant to
* prevent self-lockout.
* - Master switch `ssoOidc.enabled=false` disables enforcement for
* explicitly disabled SSO only. Runtime registration failures (tier
* downgrade, missing secret) must still fail closed for enforced
* domains.
*/
import { describe, it, expect } from 'vitest'
import { isHardBound, isSsoConfigured, type AuthProvider } from '../auth-restrictions'
Expand Down Expand Up @@ -55,9 +54,8 @@ const callIsHardBound = (
email: string | null | undefined,
role: 'admin' | 'member' | 'user',
authConfig: AuthConfig | undefined,
verifiedDomains: readonly VerifiedDomain[] | undefined,
ssoRegistered = true
) => isHardBound(provider, email, role, authConfig, verifiedDomains, ssoRegistered)
verifiedDomains: readonly VerifiedDomain[] | undefined
) => isHardBound(provider, email, role, authConfig, verifiedDomains)

describe('isSsoConfigured — master-switch helper', () => {
it('returns true when ssoOidc.enabled === true', () => {
Expand Down Expand Up @@ -141,79 +139,34 @@ describe('isHardBound — provider gate (only `sso` is exempt)', () => {
callIsHardBound('google', 'a@acme.com', 'admin', configWithSso(), [verifiedDomain])
).toBe(false)
})

it('fails open for social OAuth when SSO is not actually registered', () => {
expect(
callIsHardBound('google', 'a@acme.com', 'admin', configWithSso(), [enforcedDomain], false)
).toBe(false)
})
})

describe('isHardBound — runtime fail-open (ssoActuallyRegistered=false)', () => {
it('fails open even with an enforced verified-domain row', () => {
expect(
callIsHardBound(
'credential',
'a@acme.com',
'admin',
configWithSso(),
[enforcedDomain],
/* ssoRegistered */ false
)
).toBe(false)
})

it('fails open for magic-link too', () => {
describe('isHardBound — runtime registration failures still fail closed', () => {
it('blocks credential at an enforced verified-domain row when SSO is configured', () => {
expect(
callIsHardBound(
'magic-link',
'a@acme.com',
'admin',
configWithSso(),
[enforcedDomain],
/* ssoRegistered */ false
)
).toBe(false)
callIsHardBound('credential', 'a@acme.com', 'admin', configWithSso(), [enforcedDomain])
).toBe(true)
})

it('still blocks when registered=true and policy says so (regression: param does not invert)', () => {
it('blocks magic-link at an enforced verified-domain row when SSO is configured', () => {
expect(
callIsHardBound(
'credential',
'a@acme.com',
'admin',
configWithSso(),
[enforcedDomain],
/* ssoRegistered */ true
)
callIsHardBound('magic-link', 'a@acme.com', 'admin', configWithSso(), [enforcedDomain])
).toBe(true)
})
})

describe('isHardBound — master switch (ssoOidc.enabled)', () => {
it('returns false when ssoOidc is absent (never configured) and registered=false', () => {
expect(
callIsHardBound(
'credential',
'a@acme.com',
'admin',
baseConfig,
[enforcedDomain],
/* ssoRegistered */ false
)
).toBe(false)
it('returns false when ssoOidc is absent (never configured)', () => {
expect(callIsHardBound('credential', 'a@acme.com', 'admin', baseConfig, [enforcedDomain])).toBe(
false
)
})

it('returns false when ssoOidc.enabled=false and registered=false (stale enforced row)', () => {
it('returns false when ssoOidc.enabled=false (stale enforced row)', () => {
expect(
callIsHardBound(
'credential',
'a@acme.com',
'admin',
configWithSso({ enabled: false }),
[enforcedDomain],
/* ssoRegistered */ false
)
callIsHardBound('credential', 'a@acme.com', 'admin', configWithSso({ enabled: false }), [
enforcedDomain,
])
).toBe(false)
})
})
Expand Down
34 changes: 12 additions & 22 deletions apps/web/src/lib/server/auth/__tests__/hooks-before.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ vi.mock('@tanstack/react-start/server', () => ({
getRequestHeaders: () => new Headers(),
}))

// Default mirrors the production conditions: registered iff the admin
// has SSO enabled (so existing enforcement tests see the same blocking
// behavior). Tests for tier-downgrade / missing-secret override this.
// Retained for UI/registration code paths; hard-binding itself should
// not depend on runtime registration.
const mockIsSsoActuallyRegistered = vi.fn(
async (sso: { enabled?: boolean } | undefined, _tier: unknown) => sso?.enabled === true
)
Expand Down Expand Up @@ -118,7 +117,6 @@ beforeEach(() => {
oauth: { password: true, magicLink: false },
})
// Default mock returns true when sso.enabled is true (mirrors prod).
// Tier-downgrade / missing-secret tests override with mockResolvedValue.
mockIsSsoActuallyRegistered.mockImplementation(async (sso) => sso?.enabled === true)
mockCheckSignInRateLimit.mockResolvedValue({ allowed: true })
mockCheckMagicLinkRateLimit.mockResolvedValue({ allowed: true })
Expand Down Expand Up @@ -366,17 +364,14 @@ describe('handleSignInPreCheck — ssoOidc.enabled=false (workspace SSO disabled
})

// ============================================================
// Runtime fail-open — SSO is admin-configured but not viable
// Runtime registration failures — SSO is admin-configured but not viable
// ============================================================

describe('handleSignInPreCheck — tier-downgrade / missing-secret fail-open', () => {
// Admin has SSO enabled and an enforced verified-domain row, but the
// runtime can't actually use it: tier was downgraded or the secret got
// rotated and cleared. Layer A has already unregistered the SSO provider,
// so there's no SSO button. Without fail-open, password sign-in would
// also be blocked → total lockout. The runtime check undoes the
// enforcement until the operator fixes things.
it('allows admin password sign-in at enforced verified domain when SSO not registered (tier downgrade)', async () => {
describe('handleSignInPreCheck — tier-downgrade / missing-secret fail-closed', () => {
// Admin has SSO enabled and an enforced verified-domain row. Even if
// runtime registration fails (tier downgrade, missing secret), direct
// credential and magic-link endpoints must not become SSO bypasses.
it('blocks admin password sign-in at enforced verified domain when SSO not registered (tier downgrade)', async () => {
mockGetTenantSettings.mockResolvedValue(
tenant({ ssoEnabled: true, verifiedDomains: [makeVerifiedDomain('acme.com', true)] })
)
Expand All @@ -385,11 +380,10 @@ describe('handleSignInPreCheck — tier-downgrade / missing-secret fail-open', (
mockPrincipalFindFirst.mockResolvedValue({ role: 'admin' })
const ctx = ctxFor('/sign-in/email', { email: 'a@acme.com' })

await handleSignInPreCheck(ctx)
expect(ctx.redirect).not.toHaveBeenCalled()
await expect(handleSignInPreCheck(ctx)).rejects.toThrow(/verified_domain_requires_sso/)
})

it('allows admin magic-link too when SSO not registered', async () => {
it('blocks admin magic-link too when SSO not registered', async () => {
mockGetTenantSettings.mockResolvedValue(
tenant({
ssoEnabled: true,
Expand All @@ -401,14 +395,10 @@ describe('handleSignInPreCheck — tier-downgrade / missing-secret fail-open', (
mockPrincipalFindFirst.mockResolvedValue({ role: 'admin' })
const ctx = ctxFor('/sign-in/magic-link', { email: 'a@acme.com' })

await handleSignInPreCheck(ctx)
expect(ctx.redirect).not.toHaveBeenCalled()
await expect(handleSignInPreCheck(ctx)).rejects.toThrow(/verified_domain_requires_sso/)
})

it('still blocks when ssoRegistered=true and enforcement says so (regression)', async () => {
// Sanity: the fail-open must not invert. Same input as the
// "blocks password sign-in for admin at enforced verified domain"
// test in the per-domain suite — should still block.
it('still blocks when SSO is registered and enforcement says so (regression)', async () => {
mockGetTenantSettings.mockResolvedValue(
tenant({
ssoEnabled: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ vi.mock('@/lib/server/domains/platform-credentials/platform-credential.service',
hasPlatformCredentials: (...a: unknown[]) => mockHasPlatformCredentials(...a),
}))

// Default mirrors production: registered iff admin enabled SSO. Tests
// for tier-downgrade / missing-secret override.
// Retained for UI/registration code paths; hard-binding itself should
// not depend on runtime registration.
const mockIsSsoActuallyRegistered = vi.fn(
async (sso: { enabled?: boolean } | undefined, _tier: unknown) => sso?.enabled === true
)
Expand Down Expand Up @@ -506,7 +506,7 @@ describe('handleCallbackPolicyCleanup — hard-binding branch (enforced verified
expect(ctx.redirect).not.toHaveBeenCalled()
})

it('fails open: google at an enforced domain passes through when SSO is not actually registered', async () => {
it('fails closed: google at an enforced domain is blocked when SSO is not actually registered', async () => {
mockPrincipalFindFirst.mockResolvedValue({ role: 'admin' })
mockIsSsoActuallyRegistered.mockResolvedValue(false)
const ctx = ctxFor({
Expand All @@ -516,18 +516,16 @@ describe('handleCallbackPolicyCleanup — hard-binding branch (enforced verified
email: 'a@acme.com',
token: 'tok',
})
// googleEnabled so the method-allowed fall-through also passes —
// the point is the hard-binding branch must NOT fire when SSO
// isn't viable (self-lockout guard).
await handleCallbackPolicyCleanup(
ctx,
tenantSettings({
googleEnabled: true,
verifiedDomains: [makeVerifiedDomain('acme.com', true)],
})
)
expect(mockSessionDeleteWhere).not.toHaveBeenCalled()
expect(ctx.redirect).not.toHaveBeenCalled()
await expect(
handleCallbackPolicyCleanup(
ctx,
tenantSettings({
googleEnabled: true,
verifiedDomains: [makeVerifiedDomain('acme.com', true)],
})
)
).rejects.toThrow(/verified_domain_requires_sso/)
expect(mockSessionDeleteWhere).toHaveBeenCalled()
})

it('revokes + wipes shells when brand-new user lands via /sign-in/social with credential at enforced domain', async () => {
Expand Down
32 changes: 11 additions & 21 deletions apps/web/src/lib/server/auth/auth-restrictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,10 @@ const SSO_PROVIDER_ID: AuthProvider = 'sso'
* Layer-1 predicate: did the admin configure SSO to be on?
*
* Pure check of admin intent. Use when you need to know whether
* downstream SSO state (`required`, verified-domain `enforced`) is in
* play at all. Does NOT verify that SSO is actually viable right now —
* use {@link isHardBound} for enforcement (which fails open on runtime
* unavailability) or `isSsoActuallyRegistered` for the full viability
* check (admin intent + tier + secret).
* downstream SSO state (verified-domain `enforced`) is in play at
* all. Does NOT verify that SSO is actually viable right now —
* `isSsoActuallyRegistered` is only for provider registration/UI
* availability, not for server-side enforcement.
*
* Type predicate: narrows `sso` to non-undefined inside the guarded
* branch so callers don't need to re-check.
Expand All @@ -198,32 +197,23 @@ export function isSsoConfigured(
/**
* Unified hard-binding predicate. Returns true when the sign-in attempt
* must be rejected because the candidate email is at a verified domain
* whose `sso_verified_domain.enforced` flag is on.
* whose `sso_verified_domain.enforced` flag is on. Enforcement follows
* admin intent (`ssoOidc.enabled=true`) and deliberately does not depend
* on runtime provider registration; missing secrets or tier downgrades
* must not turn enforced domains into password/magic-link fallbacks.
*
* **Fails open when SSO isn't viable at runtime.** Callers pass
* `ssoActuallyRegistered` (computed via `isSsoActuallyRegistered`) so
* tier downgrades, missing secrets, or stale config can never cause a
* self-lockout — a team where the IdP isn't reachable should still let
* admins sign in via password/magic-link until the operator fixes it.
* Recovery codes remain available as the documented break-glass either
* way; the fail-open here covers the case where the admin doesn't know
* about recovery codes yet.
*
* @param authConfig - Reserved; kept for callsite stability. Currently
* unused — enforcement is per-verified-domain only.
* @param authConfig - Tenant auth config used for the SSO master switch.
* @param role - Reserved; kept for callsite stability. Currently unused.
*/
export function isHardBound(
provider: AuthProvider,
email: string | null | undefined,
role: Role,
authConfig: AuthConfig | undefined,
verifiedDomains: readonly VerifiedDomain[] | undefined,
ssoActuallyRegistered: boolean
verifiedDomains: readonly VerifiedDomain[] | undefined
): boolean {
if (provider === SSO_PROVIDER_ID) return false
if (!ssoActuallyRegistered) return false
void authConfig
if (!isSsoConfigured(authConfig?.ssoOidc)) return false
void role

const match = findVerifiedDomainForEmail(email, verifiedDomains)
Expand Down
26 changes: 2 additions & 24 deletions apps/web/src/lib/server/auth/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,24 +235,12 @@ export async function handleSignInPreCheck(ctx: {
: null
const role = (principalRow?.role ?? 'user') as 'admin' | 'member' | 'user'

// Pre-compute "is SSO actually viable right now?" so `isHardBound`
// can fail open on tier-downgrade / missing-secret states (else team
// admins lock themselves out the moment an upstream condition flips).
const { isSsoActuallyRegistered } = await import('./sso-secret')
const { getTierLimits } = await import('@/lib/server/domains/settings/tier-limits.service')
const ssoRegistered = await isSsoActuallyRegistered(
tenant?.authConfig?.ssoOidc,
await getTierLimits()
)

// Hard-binding: refuses password / magic-link / email-OTP for
// emails at a verified-domain row marked enforced (per-domain).
// The verified-domain branch fires before user lookup matters —
// inbox control at the verified domain shouldn't bypass the IdP's
// attestations even for brand-new sign-ups.
if (
isHardBound(provider, email, role, tenant?.authConfig, tenant?.verifiedDomains, ssoRegistered)
) {
if (isHardBound(provider, email, role, tenant?.authConfig, tenant?.verifiedDomains)) {
throw ctx.redirect('/admin/login?error=verified_domain_requires_sso')
}

Expand Down Expand Up @@ -571,16 +559,6 @@ export async function handleCallbackPolicyCleanup(
await db.delete(userTable).where(eq(userTable.id, userId as UserId))
}

// Pre-compute viability so `isHardBound` fails open on runtime
// unavailability (tier downgrade, secret missing). See the
// `isHardBound` docstring for the self-lockout rationale.
const { isSsoActuallyRegistered } = await import('./sso-secret')
const { getTierLimits } = await import('@/lib/server/domains/settings/tier-limits.service')
const ssoRegistered = await isSsoActuallyRegistered(
tenant?.authConfig?.ssoOidc,
await getTierLimits()
)

// Hard-binding for non-SSO callbacks: per-domain enforcement only
// (verified-domain row with enforced=true). `isHardBound` treats
// every provider except `sso` as hard-bindable, so this is the gate
Expand All @@ -590,7 +568,7 @@ export async function handleCallbackPolicyCleanup(
if (
provider !== 'sso' &&
typeof userEmail === 'string' &&
isHardBound(provider, userEmail, role, tenant?.authConfig, verifiedDomains, ssoRegistered)
isHardBound(provider, userEmail, role, tenant?.authConfig, verifiedDomains)
) {
await revokeSession(ctx as SessionCtx, token)
await wipeBrandNewShellsIfFresh()
Expand Down
16 changes: 4 additions & 12 deletions apps/web/src/lib/server/functions/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,26 +239,18 @@ export const fetchUserProfile = createServerFn({ method: 'GET' })
])

const { isHardBound } = await import('@/lib/server/auth/auth-restrictions')
const { isSsoActuallyRegistered } = await import('@/lib/server/auth/sso-secret')
const { getTierLimits } = await import('@/lib/server/domains/settings/tier-limits.service')
const tenant = await getTenantSettings()
const ssoRegistered = await isSsoActuallyRegistered(
tenant?.authConfig?.ssoOidc,
await getTierLimits()
)
const role = (principalRow?.role ?? 'user') as 'admin' | 'member' | 'user'
// Use the full predicate so the profile page hides the password
// section for users at an enforced verified domain. When SSO isn't
// actually viable (tier downgrade, missing secret) the predicate
// fails open — the UI then surfaces the password section as a
// fallback, mirroring the sign-in flow.
// section for users at an enforced verified domain. Enforcement
// follows admin intent and does not fail open when SSO registration
// is temporarily unavailable (tier downgrade, missing secret).
const ssoEnforced = isHardBound(
'credential',
userRecord?.email ?? null,
role,
tenant?.authConfig,
tenant?.verifiedDomains,
ssoRegistered
tenant?.verifiedDomains
)

const hasCustomAvatar = !!userRecord?.imageKey
Expand Down