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:
co-authored by
Claude Fable 5
parent
d0fb72dc63
commit
5c42852d7e
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user