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:
co-authored by
Claude Fable 5
parent
6dfaa45061
commit
fc54e79e08
@@ -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.
|
||||
|
||||
@@ -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 ────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user