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
+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.