fix(auth): scope the home-domain ok cookie to the signed-in user (#2083)

The gnubok-home-ok cache cookie stored only the host, so any account that
signed in within the 15-minute TTL window inherited the previous user's
"this is home" verdict in the same browser and skipped the brand-host
bounce entirely: a user with no ties to a white-label brand could land
inside the branded shell instead of being redirected to the canonical
domain (amnas account-switch repro, two logins 9 s apart).

The cookie value is now userId~host and the middleware only skips the
affinity check when both match the current session. Old host-only cookies
never match, so the check re-runs and the format self-migrates; no
sign-out hook is needed because a foreign verdict misses by construction.


Claude-Session: https://claude.ai/code/session_01EBJAE7b6B64HaKybjaC66B

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-31 14:58:17 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6dfaa45061
commit fc54e79e08
3 changed files with 60 additions and 14 deletions
+1
View File
@@ -1384,4 +1384,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys.
[2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total.
[2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none).
[2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict).
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
+30 -6
View File
@@ -753,7 +753,7 @@ describe('updateSession redirect destinations', () => {
expect(response.status).toBe(200)
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=arbore.accounted.se',
'gnubok-home-ok=user-1~arbore.accounted.se',
)
})
@@ -769,7 +769,7 @@ describe('updateSession redirect destinations', () => {
expect(response.status).toBe(200)
expect(locationOf(response)).toBeNull()
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=app.gnubok.se',
'gnubok-home-ok=user-1~app.gnubok.se',
)
})
@@ -785,7 +785,7 @@ describe('updateSession redirect destinations', () => {
expect(response.status).toBe(200)
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=app.gnubok.se',
'gnubok-home-ok=user-1~app.gnubok.se',
)
})
@@ -861,7 +861,7 @@ describe('updateSession redirect destinations', () => {
expect(response.status).toBe(200)
expect(locationOf(response)).toBeNull()
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=arbore.accounted.se',
'gnubok-home-ok=user-1~arbore.accounted.se',
)
expect(isEmailOnBrandAllowlist).toHaveBeenCalledWith(
'brand-arbore',
@@ -916,17 +916,41 @@ describe('updateSession redirect destinations', () => {
}
})
it('skips the check while the host-scoped OK cookie is fresh', async () => {
it('skips the check while this user\'s OK cookie for this host is fresh', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'acount.accounted.se' } } },
]
const response = await runAt(ARBORE, '/', {
cookie: 'gnubok-home-ok=arbore.accounted.se',
cookie: 'gnubok-home-ok=user-1~arbore.accounted.se',
})
expect(response.status).toBe(200)
})
it('ignores an OK cookie left behind by a DIFFERENT user and still bounces', async () => {
// The amnas account-switch repro (2026-08-31): the byrå owner signs in
// on the brand host (cookie set), signs out, and a second account with
// no ties to the brand signs in within the TTL window. The inherited
// host-only verdict skipped the bounce; the user-scoped value must not.
state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' }
const response = await runAt(ARBORE, '/', {
cookie: 'gnubok-home-ok=user-OTHER~arbore.accounted.se',
})
expect(locationOf(response)).toBe('https://app.gnubok.se/')
})
it('ignores a stale host-only cookie from the pre-user-scoped format', async () => {
state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' }
const response = await runAt(ARBORE, '/', {
cookie: 'gnubok-home-ok=arbore.accounted.se',
})
expect(locationOf(response)).toBe('https://app.gnubok.se/')
})
})
// ── MFA semantics that must not change ────────────────────────────────
+29 -8
View File
@@ -42,13 +42,28 @@ import {
const log = createLogger('proxy')
/**
* Host-scoped marker that the signed-in user is on their home domain, so the
* affinity check below costs zero queries on the hot path. Expiry re-runs the
* check, which bounds staleness after team-membership changes.
* Marker that the signed-in user is on their home domain, so the affinity
* check below costs zero queries on the hot path. Expiry re-runs the check,
* which bounds staleness after team-membership changes.
*
* The value is scoped to BOTH the user and the host (`userId~host`): a
* host-only value let anyone who signed in within the TTL window inherit the
* previous user's "this is home" verdict in the same browser, skipping the
* brand-host bounce entirely (found via the amnas account-switch repro,
* 2026-08-31). A stale host-only cookie from before this change simply never
* matches, so the check re-runs and the format migrates itself. The `~`
* separator is unreserved under encodeURIComponent AND a legal raw cookie
* octet, so the value round-trips byte-identically whether or not the cookie
* layer percent-encodes.
*/
const HOME_DOMAIN_OK_COOKIE = 'gnubok-home-ok'
const HOME_DOMAIN_OK_MAX_AGE = 15 * 60
/** The `userId~host` value a home-ok cookie must carry to skip the check. */
function homeDomainOkValue(userId: string, host: string): string {
return `${userId}~${host}`
}
/**
* Auth proxy entry point. Wraps the real work so every response carries a
* per-phase timing header and emits one structured log line, mirroring what
@@ -368,7 +383,7 @@ async function updateSessionInner(
if (homeOutcome.cacheOk) {
supabaseResponse.cookies.set(
HOME_DOMAIN_OK_COOKIE,
normalizeHost(request.nextUrl.hostname),
homeDomainOkValue(user.id, normalizeHost(request.nextUrl.hostname)),
{
path: '/',
httpOnly: true,
@@ -767,9 +782,10 @@ function isAffinityExemptHost(host: string): boolean {
* byrå's own client users log in on the byrå domain).
* 3. Everyone else stays put.
*
* `cacheOk` marks a positive "this is home" verdict, cached in a host-scoped
* cookie by the caller. Query failures fail open with no caching, so a
* transient error neither locks anyone out nor sticks for a TTL window.
* `cacheOk` marks a positive "this is home" verdict, cached by the caller in
* a cookie scoped to this user and host. Query failures fail open with no
* caching, so a transient error neither locks anyone out nor sticks for a
* TTL window.
*/
async function resolveHomeDomainOutcome(
supabase: ReturnType<typeof createServerClient>,
@@ -780,7 +796,12 @@ async function resolveHomeDomainOutcome(
const stay = { redirectTo: null, cacheOk: false }
const host = normalizeHost(request.nextUrl.hostname)
if (isAffinityExemptHost(host)) return stay
if (request.cookies.get(HOME_DOMAIN_OK_COOKIE)?.value === host) return stay
// The cached verdict must belong to THIS user: a host-only match let a
// second account signed in within the TTL window ride the first account's
// verdict and skip the brand-host bounce.
if (request.cookies.get(HOME_DOMAIN_OK_COOKIE)?.value === homeDomainOkValue(userId, host)) {
return stay
}
// Rule 1: the user's own byrå brand domains (RLS: members read their brand).
const { data: byraRows, error: byraError } = await supabase