fix(auth): exempt allowlisted emails from home-domain brand-host bounce (#2077)
A signup-allowlisted partner owner (brand_signup_allowlist) could sign up on an invite-only brand domain but was then exiled to the canonical domain by the middleware home-domain affinity rule before any team membership existed: the layout-level exemption in resolveBrandDomainBounce was unreachable because the middleware redirect runs first. Rule 2 now checks the allowlist (fail-closed) and lets those sessions stay, caching the verdict like other home verdicts. Found via the Ziffr owner lockout. Claude-Session: https://claude.ai/code/session_011eQDGWLppyy6BZ6Rx6shjd Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1a3686dd45
commit
024420d655
@@ -10,7 +10,11 @@ import { NextRequest } from 'next/server'
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
user: null as null | { id: string; app_metadata?: Record<string, unknown> },
|
||||
user: null as null | {
|
||||
id: string
|
||||
email?: string
|
||||
app_metadata?: Record<string, unknown>
|
||||
},
|
||||
sessionId: 'session-1' as string | null,
|
||||
authError: null as unknown,
|
||||
aal: null as null | { currentLevel: string; nextLevel: string },
|
||||
@@ -40,7 +44,9 @@ const state = vi.hoisted(() => ({
|
||||
clientMemberships: [] as Array<Record<string, unknown>>,
|
||||
clientMembershipsError: null as unknown,
|
||||
// What the mocked resolveBrandByHost returns for the request host.
|
||||
hostBrand: null as null | { teamId: string },
|
||||
hostBrand: null as null | { teamId: string; id?: string },
|
||||
// What the mocked isEmailOnBrandAllowlist returns (Rule 2 exemption).
|
||||
allowlisted: false,
|
||||
signOut: vi.fn(async () => ({ error: null })),
|
||||
// Row returned for user_preferences reads (the auto_logout mint lookup).
|
||||
userPreferences: null as null | { auto_logout: boolean },
|
||||
@@ -119,10 +125,17 @@ vi.mock('@/lib/logger', () => {
|
||||
vi.mock('@/lib/branding/resolve', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/branding/resolve')>()),
|
||||
resolveBrandByHost: vi.fn(async () =>
|
||||
state.hostBrand ? { teamId: state.hostBrand.teamId } : null,
|
||||
state.hostBrand
|
||||
? { id: state.hostBrand.id ?? 'brand-host', teamId: state.hostBrand.teamId }
|
||||
: null,
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/brand-signup-gate', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/auth/brand-signup-gate')>()),
|
||||
isEmailOnBrandAllowlist: vi.fn(async () => state.allowlisted),
|
||||
}))
|
||||
|
||||
import { updateSession } from '../middleware'
|
||||
import {
|
||||
createSessionTimeoutState,
|
||||
@@ -173,6 +186,7 @@ describe('updateSession redirect destinations', () => {
|
||||
state.clientMemberships = []
|
||||
state.clientMembershipsError = null
|
||||
state.hostBrand = null
|
||||
state.allowlisted = false
|
||||
state.userPreferences = null
|
||||
state.userPreferencesError = null
|
||||
delete process.env.NEXT_PUBLIC_REQUIRE_MFA
|
||||
@@ -834,6 +848,48 @@ describe('updateSession redirect destinations', () => {
|
||||
expect(locationOf(response)).toBe('https://app.gnubok.se/')
|
||||
})
|
||||
|
||||
it('keeps an allowlisted email on the brand domain before any membership exists', async () => {
|
||||
// The partner owner between allowlisted signup and team provisioning:
|
||||
// no team_members row, no company, only a brand_signup_allowlist entry.
|
||||
const { isEmailOnBrandAllowlist } = await import('@/lib/auth/brand-signup-gate')
|
||||
state.user = { ...SIGNED_IN, email: 'owner@partner.example' }
|
||||
state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' }
|
||||
state.allowlisted = true
|
||||
|
||||
const response = await runAt(ARBORE, '/')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(locationOf(response)).toBeNull()
|
||||
expect(response.headers.get('set-cookie')).toContain(
|
||||
'gnubok-home-ok=arbore.accounted.se',
|
||||
)
|
||||
expect(isEmailOnBrandAllowlist).toHaveBeenCalledWith(
|
||||
'brand-arbore',
|
||||
'owner@partner.example',
|
||||
)
|
||||
})
|
||||
|
||||
it('still redirects a non-allowlisted email off the brand domain', async () => {
|
||||
state.user = { ...SIGNED_IN, email: 'stranger@example.com' }
|
||||
state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' }
|
||||
state.allowlisted = false
|
||||
|
||||
const response = await runAt(ARBORE, '/')
|
||||
|
||||
expect(locationOf(response)).toBe('https://app.gnubok.se/')
|
||||
})
|
||||
|
||||
it('skips the allowlist lookup for a user without an email', async () => {
|
||||
const { isEmailOnBrandAllowlist } = await import('@/lib/auth/brand-signup-gate')
|
||||
state.hostBrand = { teamId: 'team-arbore' }
|
||||
state.allowlisted = true
|
||||
|
||||
const response = await runAt(ARBORE, '/')
|
||||
|
||||
expect(locationOf(response)).toBe('https://app.gnubok.se/')
|
||||
expect(isEmailOnBrandAllowlist).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a byrå client user on the byrå domain their company lives under', async () => {
|
||||
state.hostBrand = { teamId: 'team-arbore' }
|
||||
state.clientMemberships = [{ company_id: 'company-1' }]
|
||||
|
||||
@@ -14,6 +14,7 @@ import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
||||
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
|
||||
import { userHasPassword } from '@/lib/auth/has-password'
|
||||
import { isEmailOnBrandAllowlist } from '@/lib/auth/brand-signup-gate'
|
||||
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
||||
import { normalizeHost, resolveBrandByHost } from '@/lib/branding/resolve'
|
||||
import {
|
||||
@@ -355,7 +356,12 @@ async function updateSessionInner(
|
||||
// stays the answer for multi-domain company rosters. Exemption: byrå
|
||||
// staff who also have canonical-homed companies stay put on the canonical
|
||||
// host; the signpost handles per-company homing.
|
||||
const homeOutcome = await resolveHomeDomainOutcome(supabase, user.id, request)
|
||||
const homeOutcome = await resolveHomeDomainOutcome(
|
||||
supabase,
|
||||
user.id,
|
||||
user.email ?? null,
|
||||
request,
|
||||
)
|
||||
if (homeOutcome.redirectTo) {
|
||||
return NextResponse.redirect(homeOutcome.redirectTo)
|
||||
}
|
||||
@@ -768,6 +774,7 @@ function isAffinityExemptHost(host: string): boolean {
|
||||
async function resolveHomeDomainOutcome(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
userId: string,
|
||||
userEmail: string | null,
|
||||
request: NextRequest,
|
||||
): Promise<{ redirectTo: URL | null; cacheOk: boolean }> {
|
||||
const stay = { redirectTo: null, cacheOk: false }
|
||||
@@ -857,6 +864,17 @@ async function resolveHomeDomainOutcome(
|
||||
}
|
||||
if ((clientRows ?? []).length > 0) return { redirectTo: null, cacheOk: true }
|
||||
|
||||
// A signup-allowlisted email stays on the brand host even before any
|
||||
// membership exists: the partner owner between allowlisted signup and team
|
||||
// provisioning. Without this the layout-level exemption in
|
||||
// resolveBrandDomainBounce (lib/auth/brand-signup-gate.ts) is unreachable,
|
||||
// because this redirect runs first. Fail-closed lookup: an allowlist query
|
||||
// error reads as "not allowlisted" and falls through to the platform
|
||||
// redirect, which is the pre-existing behavior.
|
||||
if (userEmail && (await isEmailOnBrandAllowlist(hostBrand.id, userEmail))) {
|
||||
return { redirectTo: null, cacheOk: true }
|
||||
}
|
||||
|
||||
const platformUrl = new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://app.gnubok.se')
|
||||
if (normalizeHost(platformUrl.hostname) === host) return { redirectTo: null, cacheOk: true }
|
||||
// Preserve path + query for the same deep-link reason as the byrå hop.
|
||||
|
||||
Reference in New Issue
Block a user