fix(skatteverket): classify 400 expired-refresh-token as SESSION_EXPIRED (#1151)

The AGI kvittenser cron error-logged 'Skatteverket token refresh failed
(400): access_denied / Refresh Token status is expired' every 15 minutes
(19+ times on 2026-07-24 alone) and kept re-attempting the dead token
against SKV forever. Only the 404 id_not_found dialect of a dead refresh
session was classified as SESSION_EXPIRED; the 400 variants escaped as
raw Errors, so the cron's needs_reconsent flagging never ran.

Extend the classifier in refreshTokenForUser to also match the 400
'Refresh Token status is expired' body and OAuth2's standard 400
invalid_grant. With a typed SESSION_EXPIRED, the cron marks the token
row needs_reconsent on the first failure and resolveReadAuth skips the
connection on every later run until the user reconnects via BankID.

Config-shaped 400s (invalid_client, invalid_scope) deliberately stay
raw errors: a reconnect cannot fix those, and mislabeling them would
re-create the self-perpetuating reconnect banner from the 2026-07
MISSING_SCOPE incident.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 17:06:02 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d0fb72dc63
commit 5c42852d7e
2 changed files with 87 additions and 8 deletions
@@ -230,13 +230,14 @@ describe('skvRequestWithAuth: system mode', () => {
})
})
describe('refresh-token 404 classification', () => {
// SKV's per-flow refresh tokens live 65 minutes, so daily crons always find
// a dead token and get 404 id_not_found. That must surface as the
// SESSION_EXPIRED SkatteverketAuthError (which cron quiet-buckets and the
// UI reconnect flow understand), not as a raw Error that error-logs every
// night. Unique userIds per test: the module-level refresh coalescing map
// is keyed by userId.
describe('refresh-token dead-session classification', () => {
// SKV's per-flow refresh tokens live 65 minutes, so crons always find a
// dead token. SKV reports that in several dialects (404 id_not_found,
// 400 "Refresh Token status is expired", 400 invalid_grant); all must
// surface as the SESSION_EXPIRED SkatteverketAuthError (which cron
// quiet-buckets and the UI reconnect flow understand), not as a raw
// Error that error-logs every run. Unique userIds per test: the
// module-level refresh coalescing map is keyed by userId.
const expiredTokens = {
access_token: 'stale',
refresh_token: 'dead-refresh',
@@ -268,6 +269,72 @@ describe('refresh-token 404 classification', () => {
}
})
it('classifies 400 "Refresh Token status is expired" as SESSION_EXPIRED', async () => {
// Exact prod payload observed 2026-07-24: the AGI kvittenser cron hit
// this every 15 minutes and error-logged it because only the 404
// dialect was classified.
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error(
'Skatteverket token refresh failed (400): {\n "error":"access_denied",\n "error_description":"Refresh Token status is expired"\n}\n',
),
)
try {
await skvRequest(fakeSupabase, 'user-400-expired', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
expect((e as SkatteverketAuthError).message).toMatch(/Sessionen har gått ut/)
}
})
it('classifies 400 invalid_grant as SESSION_EXPIRED', async () => {
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error('Skatteverket token refresh failed (400): {"error": "invalid_grant"}'),
)
try {
await skvRequest(fakeSupabase, 'user-400-grant', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
}
})
it('leaves config-shaped 400s (invalid_client) as raw errors', async () => {
// invalid_client means OUR client credentials are broken; telling the
// user to reconnect cannot fix it and would re-create the
// self-perpetuating reconnect banner (2026-07 MISSING_SCOPE incident).
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error('Skatteverket token refresh failed (400): {"error":"invalid_client"}'),
)
try {
await skvRequest(fakeSupabase, 'user-400-client', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).not.toBeInstanceOf(SkatteverketAuthError)
expect((e as Error).message).toMatch(/invalid_client/)
}
})
it('re-throws other refresh failures untouched', async () => {
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
@@ -187,8 +187,20 @@ async function refreshTokenForUser(
// gets 404 id_not_found back — that's ordinary session expiry, not a
// runtime error. Classify it so the crons' quiet buckets and the UI's
// reconnect flow catch it instead of a raw Error escaping to the logs.
// SKV speaks several dialects for the same terminal state: 404 with
// id_not_found / "refresh token is not found", 400 access_denied with
// "Refresh Token status is expired", and OAuth2's standard 400
// invalid_grant. Config-shaped 400s (invalid_client, invalid_scope)
// deliberately stay raw errors: telling the user to reconnect cannot
// fix those, and mislabeling them re-creates the self-perpetuating
// reconnect banner from the 2026-07 MISSING_SCOPE incident.
const message = err instanceof Error ? err.message : String(err)
if (/\b404\b/.test(message) && /id_not_found|refresh token is not found/i.test(message)) {
const deadRefreshToken =
(/\b404\b/.test(message) && /id_not_found|refresh token is not found/i.test(message)) ||
(/\b400\b/.test(message) &&
(/refresh token status is expired/i.test(message) ||
/"error"\s*:\s*"invalid_grant"/i.test(message)))
if (deadRefreshToken) {
throw new SkatteverketAuthError(
'Sessionen har gått ut. Logga in med BankID igen.',
'SESSION_EXPIRED'