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:
Mattsson
2026-08-31 13:44:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1a3686dd45
commit 024420d655
3 changed files with 79 additions and 4 deletions
+59 -3
View File
@@ -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' }]
+19 -1
View File
@@ -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.