Files
accounted/lib/auth/__tests__/oauth-flows.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

369 lines
14 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const { mockResolveBrandByHost } = vi.hoisted(() => ({ mockResolveBrandByHost: vi.fn() }))
vi.mock('@/lib/branding/resolve', () => ({ resolveBrandByHost: mockResolveBrandByHost }))
import {
consumeOAuthFlowHandoff,
consumeOAuthFlowState,
createOAuthFlow,
mintOAuthFlowHandoff,
newOAuthFlowId,
peekOAuthFlowHandoff,
peekOAuthFlowState,
purgeExpiredOAuthFlows,
requestHost,
requestMatchesOrigin,
resolveOAuthOrigin,
type OAuthFlow,
} from '../oauth-flows'
import { decryptOAuthFlowValue, encryptOAuthFlowValue } from '../oauth-flow-crypto'
/**
* Chainable Supabase mock that records every builder call. Awaiting the
* chain (insert / delete without select) resolves `terminal`; maybeSingle()
* resolves it too.
*/
function makeDb(terminal: { data?: any; error?: any } = { data: null, error: null }) {
const calls: Array<[string, unknown[]]> = []
const chain: any = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'then') {
return (resolve: any, reject: any) => Promise.resolve(terminal).then(resolve, reject)
}
if (prop === 'maybeSingle') return vi.fn(async () => terminal)
return (...args: unknown[]) => {
calls.push([prop, args])
return chain
}
},
},
)
const db = { from: vi.fn(() => chain) }
const call = (name: string) => calls.find(([n]) => n === name)?.[1]
const payload = (name: string) => call(name)?.[0] as Record<string, any>
return { db: db as any, calls, call, payload }
}
const FLOW: OAuthFlow = {
id: 'state-1',
kind: 'skatteverket',
companyId: 'company-1',
userId: 'user-1',
origin: 'https://brand.example',
redirectUri: 'https://oauth.example/cb',
codeVerifier: 'verifier-1',
connectorState: null,
returnTo: '/settings/tax',
}
function rowFor(flow: OAuthFlow, extra: Record<string, unknown> = {}) {
return {
id: flow.id,
kind: flow.kind,
company_id: flow.companyId,
user_id: flow.userId,
origin: flow.origin,
redirect_uri: flow.redirectUri,
code_verifier:
flow.codeVerifier === null
? null
: encryptOAuthFlowValue(
flow.codeVerifier,
JSON.stringify([flow.id, flow.userId, flow.origin, 'code_verifier']),
),
connector_state: flow.connectorState,
return_to: flow.returnTo,
...extra,
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-service-role-secret')
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example')
mockResolveBrandByHost.mockResolvedValue(null)
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('oauth-flow-crypto', () => {
it('round-trips under the same context and refuses another', () => {
const ct = encryptOAuthFlowValue('secret', 'ctx-a')
expect(ct.startsWith('v1:')).toBe(true)
expect(ct).not.toContain('secret')
expect(decryptOAuthFlowValue(ct, 'ctx-a')).toBe('secret')
expect(() => decryptOAuthFlowValue(ct, 'ctx-b')).toThrow()
})
it('never accepts a plaintext value as a decryption', () => {
expect(() => decryptOAuthFlowValue('secret', 'ctx')).toThrow(/ciphertext/)
})
})
describe('requestHost', () => {
it('prefers the Host header over the reconstructed URL and normalises it', () => {
const req = new Request('https://internal.example/cb', { headers: { host: 'Brand.Example.' } })
expect(requestHost(req)).toBe('brand.example')
})
it('falls back to the URL host for a malformed Host header', () => {
const req = new Request('https://app.example/cb', { headers: { host: 'evil.example/path' } })
expect(requestHost(req)).toBe('app.example')
})
it('matches an origin by host regardless of the scheme the proxy reported', () => {
// A TLS-terminating proxy that drops x-forwarded-proto makes Next
// reconstruct request.url as http://; the host is what identifies the hop.
const req = new Request('http://app.example/cb')
expect(requestMatchesOrigin(req, 'https://app.example')).toBe(true)
expect(requestMatchesOrigin(req, 'https://oauth.example')).toBe(false)
})
})
describe('resolveOAuthOrigin', () => {
it('returns the app origin for the app host without a brand lookup, whatever scheme the proxy reported', async () => {
expect(await resolveOAuthOrigin(new Request('https://app.example/x'))).toBe('https://app.example')
expect(await resolveOAuthOrigin(new Request('http://app.example/x'))).toBe('https://app.example')
expect(await resolveOAuthOrigin(new Request('http://APP.example./x'))).toBe('https://app.example')
expect(mockResolveBrandByHost).not.toHaveBeenCalled()
})
it('treats an internal proxy upstream host as the app origin', async () => {
// nginx defaults: Host rewritten to the upstream address, no proto header.
expect(await resolveOAuthOrigin(new Request('http://127.0.0.1:3000/x'))).toBe('https://app.example')
expect(mockResolveBrandByHost).not.toHaveBeenCalled()
})
it('returns a brand origin only when the brands table resolves the exact host', async () => {
mockResolveBrandByHost.mockResolvedValue({ domain: 'brand.example' })
expect(await resolveOAuthOrigin(new Request('https://brand.example/x'))).toBe('https://brand.example')
mockResolveBrandByHost.mockResolvedValue({ domain: 'other.example' })
expect(await resolveOAuthOrigin(new Request('https://brand.example/x'))).toBe('https://app.example')
mockResolveBrandByHost.mockResolvedValue(null)
expect(await resolveOAuthOrigin(new Request('https://stranger.example/x'))).toBe('https://app.example')
})
it('always answers a known brand as HTTPS and never a non-default port', async () => {
mockResolveBrandByHost.mockResolvedValue({ domain: 'brand.example' })
expect(await resolveOAuthOrigin(new Request('http://brand.example/x'))).toBe('https://brand.example')
expect(await resolveOAuthOrigin(new Request('https://brand.example:8443/x'))).toBe('https://app.example')
})
})
describe('createOAuthFlow', () => {
it('stores the verifier encrypted and bound to the row identity', async () => {
const { db, payload } = makeDb({ error: null })
await createOAuthFlow(db, {
id: 'state-1',
kind: 'skatteverket',
companyId: 'company-1',
userId: 'user-1',
origin: 'https://brand.example',
redirectUri: 'https://oauth.example/cb',
codeVerifier: 'verifier-1',
connectorState: 'cs',
returnTo: '/settings/tax',
})
const row = payload('insert')
expect(row).toMatchObject({
id: 'state-1',
kind: 'skatteverket',
company_id: 'company-1',
user_id: 'user-1',
origin: 'https://brand.example',
redirect_uri: 'https://oauth.example/cb',
connector_state: 'cs',
return_to: '/settings/tax',
})
expect(row.code_verifier).not.toContain('verifier-1')
expect(
decryptOAuthFlowValue(
row.code_verifier,
JSON.stringify(['state-1', 'user-1', 'https://brand.example', 'code_verifier']),
),
).toBe('verifier-1')
const ttl = new Date(row.expires_at).getTime() - Date.now()
expect(ttl).toBeGreaterThan(9 * 60 * 1000)
expect(ttl).toBeLessThanOrEqual(10 * 60 * 1000)
})
it('throws when the insert fails', async () => {
const { db } = makeDb({ error: { message: 'boom' } })
await expect(
createOAuthFlow(db, {
id: 's',
kind: 'skatteverket',
companyId: 'c',
userId: 'u',
origin: 'https://app.example',
redirectUri: 'https://app.example/cb',
}),
).rejects.toThrow(/boom/)
})
it('mints unguessable ids', () => {
const a = newOAuthFlowId()
expect(a).toHaveLength(43)
expect(newOAuthFlowId()).not.toBe(a)
})
})
describe('consumeOAuthFlowState', () => {
it('consumes with the whole check in the predicate and returns the decrypted flow', async () => {
const { db, calls, call } = makeDb({ data: rowFor(FLOW), error: null })
const flow = await consumeOAuthFlowState(db, 'state-1', 'skatteverket')
expect(flow).toEqual(FLOW)
expect(call('update')?.[0]).toMatchObject({ used_at: expect.any(String) })
expect(calls).toEqual(
expect.arrayContaining([
['eq', ['id', 'state-1']],
['eq', ['kind', 'skatteverket']],
['is', ['used_at', null]],
['gt', ['expires_at', expect.any(String)]],
]),
)
})
it('returns null for no row, a query error, and an unreadable verifier', async () => {
expect(await consumeOAuthFlowState(makeDb({ data: null, error: null }).db, 's', 'skatteverket')).toBeNull()
expect(
await consumeOAuthFlowState(makeDb({ data: null, error: { message: 'x' } }).db, 's', 'skatteverket'),
).toBeNull()
const tampered = rowFor(FLOW, { code_verifier: 'v1:not-really' })
expect(await consumeOAuthFlowState(makeDb({ data: tampered, error: null }).db, 's', 'skatteverket')).toBeNull()
})
})
describe('mintOAuthFlowHandoff / consumeOAuthFlowHandoff', () => {
it('stashes the provider code encrypted under a fresh handoff id, only onto a consumed row', async () => {
const { db, calls, payload } = makeDb({ data: { id: 'state-1' }, error: null })
const handoffId = await mintOAuthFlowHandoff(db, FLOW, { providerCode: 'code-1' })
expect(handoffId).not.toBe(FLOW.id)
const row = payload('update')
expect(row.handoff_id).toBe(handoffId)
expect(row.handoff_error).toBeNull()
expect(row.handoff_code).not.toContain('code-1')
expect(
decryptOAuthFlowValue(
row.handoff_code,
JSON.stringify(['state-1', handoffId, 'user-1', 'https://brand.example', 'handoff_code']),
),
).toBe('code-1')
const ttl = new Date(row.handoff_expires_at).getTime() - Date.now()
expect(ttl).toBeGreaterThan(4 * 60 * 1000)
expect(ttl).toBeLessThanOrEqual(5 * 60 * 1000)
expect(calls).toEqual(
expect.arrayContaining([
['eq', ['id', 'state-1']],
['is', ['handoff_id', null]],
['not', ['used_at', 'is', null]],
]),
)
})
it('throws when the row was already handed off', async () => {
const { db } = makeDb({ data: null, error: null })
await expect(mintOAuthFlowHandoff(db, FLOW, { providerError: 'nej' })).rejects.toThrow(/handed off/)
})
it('claims the handoff bound to the origin and returns the decrypted result', async () => {
const handoffId = 'handoff-1'
const ctx = (col: string) => JSON.stringify(['state-1', handoffId, 'user-1', 'https://brand.example', col])
const row = rowFor(FLOW, {
handoff_code: encryptOAuthFlowValue('code-1', ctx('handoff_code')),
handoff_error: null,
})
const { db, calls } = makeDb({ data: row, error: null })
const result = await consumeOAuthFlowHandoff(db, handoffId, 'https://brand.example', 'skatteverket')
expect(result).toEqual({ ...FLOW, providerCode: 'code-1', providerError: null })
expect(calls[0]?.[0]).toBe('delete')
expect(calls).toEqual(
expect.arrayContaining([
['eq', ['handoff_id', handoffId]],
['eq', ['origin', 'https://brand.example']],
['eq', ['kind', 'skatteverket']],
['gt', ['handoff_expires_at', expect.any(String)]],
]),
)
})
it('returns null for a missing row and for a tampered payload', async () => {
expect(
await consumeOAuthFlowHandoff(makeDb({ data: null, error: null }).db, 'h', 'https://brand.example', 'skatteverket'),
).toBeNull()
const row = rowFor(FLOW, { handoff_code: 'v1:garbage', handoff_error: null })
expect(
await consumeOAuthFlowHandoff(makeDb({ data: row, error: null }).db, 'h', 'https://brand.example', 'skatteverket'),
).toBeNull()
})
})
describe('peekOAuthFlowState / peekOAuthFlowHandoff', () => {
it('reads a live state identity without writing, with the same liveness predicate as the consume', async () => {
const { db, calls } = makeDb({ data: { user_id: 'user-1', company_id: 'company-1', origin: 'https://brand.example' }, error: null })
expect(await peekOAuthFlowState(db, 'state-1', 'skatteverket')).toEqual({
userId: 'user-1',
companyId: 'company-1',
origin: 'https://brand.example',
})
expect(calls[0]?.[0]).toBe('select')
expect(calls.map(([n]) => n)).not.toContain('update')
expect(calls.map(([n]) => n)).not.toContain('delete')
expect(calls).toEqual(
expect.arrayContaining([
['eq', ['id', 'state-1']],
['eq', ['kind', 'skatteverket']],
['is', ['used_at', null]],
['gt', ['expires_at', expect.any(String)]],
]),
)
})
it('reads a live handoff identity bound to the origin, without writing', async () => {
const { db, calls } = makeDb({ data: { user_id: 'user-1', company_id: 'company-1', origin: 'https://brand.example' }, error: null })
expect(await peekOAuthFlowHandoff(db, 'handoff-1', 'https://brand.example', 'skatteverket')).toEqual({
userId: 'user-1',
companyId: 'company-1',
origin: 'https://brand.example',
})
expect(calls[0]?.[0]).toBe('select')
expect(calls).toEqual(
expect.arrayContaining([
['eq', ['handoff_id', 'handoff-1']],
['eq', ['origin', 'https://brand.example']],
['eq', ['kind', 'skatteverket']],
['gt', ['handoff_expires_at', expect.any(String)]],
]),
)
})
it('returns null for no row or a query error', async () => {
expect(await peekOAuthFlowState(makeDb({ data: null, error: null }).db, 's', 'skatteverket')).toBeNull()
expect(
await peekOAuthFlowHandoff(makeDb({ data: null, error: { message: 'x' } }).db, 'h', 'https://brand.example', 'skatteverket'),
).toBeNull()
})
})
describe('purgeExpiredOAuthFlows', () => {
it('deletes only rows nothing can consume any more', async () => {
const { db, calls } = makeDb({ error: null })
await purgeExpiredOAuthFlows(db)
expect(calls[0]?.[0]).toBe('delete')
expect(calls).toEqual(
expect.arrayContaining([
['lt', ['expires_at', expect.any(String)]],
['or', [expect.stringMatching(/^handoff_expires_at\.is\.null,handoff_expires_at\.lt\./)]],
]),
)
})
})