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