fix(domains,skatteverket): #1087 follow-ups: auth-path redirect exclusions, SKV callback hardening (#1094)

- Exclude auth/ and reset-password from the legacy-host redirect (#1092):
  email links sent before the cutover carry a PKCE code or recovery
  session whose cookies live on app.gnubok.se; forwarding them to the
  new domain breaks password resets and signup confirmations clicked
  after the flip. login/MFA stay redirected on purpose: a usable login
  page on the legacy host would establish sessions there and loop.
  Exclusion pattern extracted to lib/domains/legacy-redirect.ts with a
  unit test pinning the behavior.
- Clean up ephemeral oauth state rows (incl. oauth_user_id) when the
  SKV token exchange fails (#1090): identity data must not outlive the
  flow; best-effort so cleanup failure never masks the user-facing error.
- Assert the stored user is still a member of the company before the
  service-role storeTokens write (#1091): membership can be revoked
  between /authorize and the callback, and RLS no longer backstops the
  write. Checked before the exchange so the one-shot code is not burned.

Fixes #1090, fixes #1091, fixes #1092.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-21 13:48:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e33cc2428d
commit 335d908614
5 changed files with 152 additions and 8 deletions
@@ -55,7 +55,10 @@ const STATE = 'state-1'
* whichever key the chain last filtered on, and the post-exchange cleanup
* delete resolves via .in().
*/
function makeServiceSupabase(overrides: Record<string, string | null> = {}) {
function makeServiceSupabase(
overrides: Record<string, string | null> = {},
options: { isMember?: boolean } = {},
) {
const values: Record<string, string | null> = {
oauth_state: STATE,
oauth_user_id: 'user-1',
@@ -64,8 +67,10 @@ function makeServiceSupabase(overrides: Record<string, string | null> = {}) {
oauth_return_to: '/settings/tax',
...overrides,
}
const isMember = options.isMember ?? true
const gte = vi.fn()
const from = vi.fn(() => {
const inCalls: string[][] = []
const from = vi.fn((table: string) => {
let key: string | null = null
const chain: any = {
select: vi.fn(() => chain),
@@ -75,10 +80,18 @@ function makeServiceSupabase(overrides: Record<string, string | null> = {}) {
return chain
}),
gte: gte.mockImplementation(() => chain),
in: vi.fn(() => Promise.resolve({ error: null })),
maybeSingle: vi.fn(async () => ({
data: key !== null && values[key] != null ? { value: values[key] } : null,
})),
in: vi.fn((_col: string, keys: string[]) => {
inCalls.push(keys)
return Promise.resolve({ error: null })
}),
maybeSingle: vi.fn(async () => {
if (table === 'company_members') {
return { data: isMember ? { user_id: values.oauth_user_id ?? 'user-1' } : null }
}
return {
data: key !== null && values[key] != null ? { value: values[key] } : null,
}
}),
then: (resolve: any, reject: any) => {
const rows =
values.oauth_state != null
@@ -89,7 +102,7 @@ function makeServiceSupabase(overrides: Record<string, string | null> = {}) {
}
return chain
})
return { from, gte }
return { from, gte, inCalls }
}
/** Cookie-bound client: only consulted by the legacy session fallback. */
@@ -250,4 +263,38 @@ describe('skatteverket OAuth callback', () => {
expect(html).toContain('skatteverket-oauth-error')
expect(mockRefresh).not.toHaveBeenCalled()
})
it('cleans up the ephemeral state rows (incl. oauth_user_id) when the exchange fails', async () => {
const service = makeServiceSupabase()
mockCreateServiceClient.mockReturnValue(service as any)
mockExchange.mockRejectedValueOnce(new Error('exchange boom'))
await callbackRoute().handler(callbackRequest(`code=abc&state=${STATE}`))
// The failure path must delete the same ephemeral keys the success
// path does: oauth_user_id holds a user identity and must not be
// retained past the flow. (#1090)
expect(service.inCalls).toHaveLength(1)
expect(service.inCalls[0]).toEqual(
expect.arrayContaining(['oauth_state', 'oauth_user_id', 'oauth_code_verifier']),
)
})
it('rejects the flow when the stored user is no longer a member of the company', async () => {
mockCreateServiceClient.mockReturnValue(
makeServiceSupabase({}, { isMember: false }) as any,
)
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('skatteverket-oauth-error')
// Rejected before the exchange so the one-shot code is not burned,
// and no token write is attempted. (#1091)
expect(mockExchange).not.toHaveBeenCalled()
expect(mockStoreTokens).not.toHaveBeenCalled()
})
})
+33
View File
@@ -423,6 +423,25 @@ export const skatteverketExtension: Extension = {
)
}
// Defense in depth for the service-role write below: the stored
// user must still be a member of the company that initiated the
// flow (membership can be revoked between /authorize and this
// callback, and RLS no longer backstops the write). Checked before
// the exchange so a rejected flow does not burn the one-shot
// authorization code. (#1091)
const { data: membership } = await db
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!membership) {
return respondWithError(
'Behörighet saknas för företaget',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Behörighet saknas för företaget')}`,
)
}
const redirectUri = (await readSetting('oauth_redirect_uri')) ||
`${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
@@ -481,6 +500,20 @@ export const skatteverketExtension: Extension = {
return respondWithSuccess(successPath)
} catch (err) {
console.error('[skatteverket] Token exchange failed:', err)
// The ephemeral flow rows must not outlive the flow: oauth_user_id
// in particular holds a user identity and serves no purpose once
// the exchange has failed (#1090). Best-effort: a cleanup failure
// must not mask the exchange error shown to the user.
try {
await db
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier'])
} catch (cleanupErr) {
log.error('oauth state cleanup after failed exchange failed', cleanupErr, { companyId })
}
// BankID auth codes expire after 5 minutes. Surface timeouts distinctly
// so the user retries quickly instead of exhausting the code window.
const message = err instanceof TimeoutError