diff --git a/extensions/general/skatteverket/__tests__/api-client.test.ts b/extensions/general/skatteverket/__tests__/api-client.test.ts index 4a19cd99..83329ba1 100644 --- a/extensions/general/skatteverket/__tests__/api-client.test.ts +++ b/extensions/general/skatteverket/__tests__/api-client.test.ts @@ -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') diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 03d63f6f..37d7c6fb 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -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'