Files
accounted/extensions/general/skatteverket/__tests__/oauth-callback.test.ts
T
Jakob WennbergandClaude Fable 5 335d908614 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>
2026-07-21 13:48:01 +02:00

301 lines
11 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
// after() must be observable: the callback hands it the eager refresh
// promise so the serverless function stays alive past the response.
vi.mock('next/server', async (importOriginal) => {
const actual = await importOriginal<typeof import('next/server')>()
return { ...actual, after: vi.fn() }
})
vi.mock('../lib/oauth', () => ({
buildAuthorizeUrl: vi.fn().mockReturnValue('https://skv.test/authorize'),
generatePkcePair: vi.fn().mockReturnValue({ verifier: 'v', challenge: 'c' }),
exchangeCodeForTokens: vi.fn(),
}))
vi.mock('../lib/token-store', () => ({
storeTokens: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn().mockResolvedValue(null),
deleteTokens: vi.fn().mockResolvedValue(undefined),
getTokenHealth: vi.fn().mockResolvedValue(null),
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
RECONSENT_ERROR_CODES: ['SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'MISSING_SCOPE', 'TOKEN_CORRUPTED'],
}))
vi.mock('../lib/post-connect-refresh', () => ({
runPostConnectRefresh: vi.fn(),
}))
const { mockCreateClient, mockCreateServiceClient } = vi.hoisted(() => ({
mockCreateClient: vi.fn(),
mockCreateServiceClient: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
createServiceClient: mockCreateServiceClient,
}))
import { after } from 'next/server'
import { skatteverketExtension } from '../index'
import { exchangeCodeForTokens } from '../lib/oauth'
import { storeTokens } from '../lib/token-store'
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
const mockExchange = vi.mocked(exchangeCodeForTokens)
const mockStoreTokens = vi.mocked(storeTokens)
const mockRefresh = vi.mocked(runPostConnectRefresh)
const STATE = 'state-1'
/**
* Service-client mock covering the callback's extension_data access:
* awaiting the query chain directly returns the oauth_state row listing
* (the company resolution), .maybeSingle() returns the per-key setting for
* whichever key the chain last filtered on, and the post-exchange cleanup
* delete resolves via .in().
*/
function makeServiceSupabase(
overrides: Record<string, string | null> = {},
options: { isMember?: boolean } = {},
) {
const values: Record<string, string | null> = {
oauth_state: STATE,
oauth_user_id: 'user-1',
oauth_redirect_uri: 'https://app.example/api/extensions/ext/skatteverket/callback',
oauth_code_verifier: 'verifier-1',
oauth_return_to: '/settings/tax',
...overrides,
}
const isMember = options.isMember ?? true
const gte = vi.fn()
const inCalls: string[][] = []
const from = vi.fn((table: string) => {
let key: string | null = null
const chain: any = {
select: vi.fn(() => chain),
delete: vi.fn(() => chain),
eq: vi.fn((col: string, val: string) => {
if (col === 'key') key = val
return chain
}),
gte: gte.mockImplementation(() => chain),
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
? [{ company_id: 'company-1', value: values.oauth_state }]
: []
return Promise.resolve({ data: rows }).then(resolve, reject)
},
}
return chain
})
return { from, gte, inCalls }
}
/** Cookie-bound client: only consulted by the legacy session fallback. */
function makeCookieClient(userId: string | null) {
return {
auth: {
getUser: vi.fn(async () => ({ data: { user: userId ? { id: userId } : null } })),
},
}
}
function callbackRoute() {
const route = skatteverketExtension.apiRoutes?.find(
(r) => r.method === 'GET' && r.path === '/callback',
)
expect(route, 'GET /callback must be registered').toBeDefined()
expect(route!.skipAuth).toBe(true)
return route!
}
function callbackRequest(params: string) {
return new Request(
`https://app.example/api/extensions/ext/skatteverket/callback?${params}`,
)
}
describe('skatteverket OAuth callback', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateServiceClient.mockReturnValue(makeServiceSupabase() as any)
// No session cookies by default: the callback is served on the pinned
// OAuth host (app.gnubok.se), where the user-facing app's session does
// not exist. Every happy-path test doubles as a cookie-free proof.
mockCreateClient.mockResolvedValue(makeCookieClient(null) as any)
mockExchange.mockResolvedValue({
access_token: 'at',
refresh_token: 'rt',
expires_at: Date.now() + 3_600_000,
refresh_count: 0,
scope: 'momsdeklaration skahmst agd',
})
})
it('responds with the success page WITHOUT awaiting the post-connect refresh', async () => {
// A refresh that never settles: if the handler regressed to awaiting it,
// this test would hang into the vitest timeout instead of passing.
let refreshStarted = false
mockRefresh.mockImplementation(() => {
refreshStarted = true
return new Promise(() => {})
})
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-success')
expect(html).toContain('window.close()')
expect(mockExchange).toHaveBeenCalledWith(
'abc',
'https://app.example/api/extensions/ext/skatteverket/callback',
'verifier-1',
)
expect(mockStoreTokens).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
// The stored oauth_user_id resolved the user; the cookie-bound client
// must never be needed on the modern path.
expect(mockCreateClient).not.toHaveBeenCalled()
// The state lookup must be recency-bounded: an old state row (leaked or
// phished authorize URL) must not stay completable indefinitely.
const service = mockCreateServiceClient.mock.results[0]!.value
expect(service.gte).toHaveBeenCalledWith('updated_at', expect.any(String))
// The refresh was started eagerly and handed to after() so it survives
// past the response; it must not gate the response itself.
expect(refreshStarted).toBe(true)
expect(vi.mocked(after)).toHaveBeenCalledTimes(1)
})
it('still succeeds when after() is unavailable (outside a request scope)', async () => {
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 })
vi.mocked(after).mockImplementation(() => {
throw new Error('after called outside request scope')
})
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
})
it('falls back to the session cookie for flows started before oauth_user_id shipped', async () => {
mockCreateServiceClient.mockReturnValue(
makeServiceSupabase({ oauth_user_id: null }) as any,
)
mockCreateClient.mockResolvedValue(makeCookieClient('legacy-user') as any)
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 })
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
expect(mockStoreTokens).toHaveBeenCalledWith(
expect.anything(),
'legacy-user',
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
})
it('returns the error page when neither a stored user id nor a session exists', async () => {
mockCreateServiceClient.mockReturnValue(
makeServiceSupabase({ oauth_user_id: null }) 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')
expect(mockExchange).not.toHaveBeenCalled()
expect(mockRefresh).not.toHaveBeenCalled()
})
it('returns the error page on a state (CSRF) mismatch without exchanging the code', async () => {
const response = await callbackRoute().handler(
callbackRequest('code=abc&state=wrong-state'),
)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('skatteverket-oauth-error')
expect(mockExchange).not.toHaveBeenCalled()
expect(mockRefresh).not.toHaveBeenCalled()
})
it('returns the error page when the token exchange fails', async () => {
mockExchange.mockRejectedValueOnce(new Error('exchange boom'))
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')
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()
})
})