diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index 424896fb1c..8af2f33106 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -51,6 +51,7 @@ "@types/koa": "^2.13.5", "@types/sanitize-html": "^2.16.1", "@types/supertest": "^6.0.2", + "joi": "^17.13.6", "openapi3-ts": "4.6.1", "redoc": "2.5.3", "supertest": "^7.1.3" diff --git a/packages/agent-bff/src/ai/ai-routes-middleware.ts b/packages/agent-bff/src/ai/ai-routes-middleware.ts index eba4ad4e24..ec80dfc9d7 100644 --- a/packages/agent-bff/src/ai/ai-routes-middleware.ts +++ b/packages/agent-bff/src/ai/ai-routes-middleware.ts @@ -91,7 +91,7 @@ async function resolveSessionAccessToken( logger: Logger, ): Promise { try { - return await ensureFreshServerAccess({ sid: principal.sid, store, serverClient }); + return await ensureFreshServerAccess({ sid: principal.sid, store, serverClient, logger }); } catch (error) { if (error instanceof OAuthRequestError && error.status >= 500) { logger('Warn', 'AI query refused: the Forest server could not refresh the session', { diff --git a/packages/agent-bff/src/auth/forest-server-token-middleware.ts b/packages/agent-bff/src/auth/forest-server-token-middleware.ts index 70ed75ef97..0ba2792d21 100644 --- a/packages/agent-bff/src/auth/forest-server-token-middleware.ts +++ b/packages/agent-bff/src/auth/forest-server-token-middleware.ts @@ -57,6 +57,7 @@ async function resolveToken( sid: principal.sid, store: session.store, serverClient: session.serverClient, + logger, }); } catch (error) { // The errors below carry neither the cause nor a `cause` field, so this line is the only place diff --git a/packages/agent-bff/src/oauth/forest-server-client.ts b/packages/agent-bff/src/oauth/forest-server-client.ts index ad3accdaf8..899948560f 100644 --- a/packages/agent-bff/src/oauth/forest-server-client.ts +++ b/packages/agent-bff/src/oauth/forest-server-client.ts @@ -29,6 +29,11 @@ export interface ExchangeCodeParams { clientId: string; } +export interface RefreshServerTokenParams { + refreshToken: string; + clientId: string; +} + export interface ForestServerClientOptions { forestServerUrl: string; envSecret: string; @@ -142,8 +147,14 @@ export default class ForestServerClient { ); } - async refreshServerToken(saasRefreshToken: string): Promise { - return this.postToken({ grant_type: 'refresh_token', refresh_token: saasRefreshToken }, false); + async refreshServerToken({ + refreshToken, + clientId, + }: RefreshServerTokenParams): Promise { + return this.postToken( + { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId }, + false, + ); } private async postToken( diff --git a/packages/agent-bff/src/oauth/oauth-routes.ts b/packages/agent-bff/src/oauth/oauth-routes.ts index 61e55d46f3..96c893be90 100644 --- a/packages/agent-bff/src/oauth/oauth-routes.ts +++ b/packages/agent-bff/src/oauth/oauth-routes.ts @@ -349,6 +349,7 @@ async function handleAuthorizationCodeGrant( saasRefreshToken: serverTokens.saasRefreshToken, renderingId: serverTokens.renderingId, userId: user.id, + clientId: request.clientId, }); const accessToken = issueBffAccessToken({ @@ -404,6 +405,7 @@ async function issueAccessForSession( sid, store: options.sessionStore, serverClient: options.serverClient, + logger: options.logger, }); const expiresInSeconds = expiresInFromAccessToken(saasAccessToken); diff --git a/packages/agent-bff/src/oauth/session-lifecycle.ts b/packages/agent-bff/src/oauth/session-lifecycle.ts index 35a841fc6b..d28dfbaeab 100644 --- a/packages/agent-bff/src/oauth/session-lifecycle.ts +++ b/packages/agent-bff/src/oauth/session-lifecycle.ts @@ -1,5 +1,6 @@ import type ForestServerClient from './forest-server-client'; -import type { SessionStore } from './session-store'; +import type { SessionStore, StoredSession } from './session-store'; +import type { Logger } from '../ports/logger-port'; import jsonwebtoken from 'jsonwebtoken'; @@ -10,6 +11,7 @@ export interface EnsureFreshServerAccessParams { sid: string; store: SessionStore; serverClient: ForestServerClient; + logger: Logger; } const CLIENT_ERROR_CODES = new Set(['invalid_grant', 'invalid_request', 'invalid_client']); @@ -23,10 +25,21 @@ function accessTokenExpiry(saasAccessToken: string): number { } async function refreshAndPersist( - sid: string, - store: SessionStore, - serverClient: ForestServerClient, + session: StoredSession, + { sid, store, serverClient, logger }: EnsureFreshServerAccessParams, ): Promise { + const sessionContext = { renderingId: session.renderingId, userId: session.userId }; + + if (!session.clientId) { + logger( + 'Warn', + 'The session has no client id to refresh the Forest server token', + sessionContext, + ); + + throw sessionExpired('Session not found or expired'); + } + const currentRefresh = store.getSaasRefreshToken(sid); if (currentRefresh === undefined) { @@ -36,9 +49,18 @@ async function refreshAndPersist( let rotated: Awaited>; try { - rotated = await serverClient.refreshServerToken(currentRefresh); + rotated = await serverClient.refreshServerToken({ + refreshToken: currentRefresh, + clientId: session.clientId, + }); } catch (error) { if (error instanceof OAuthExchangeError && CLIENT_ERROR_CODES.has(error.error)) { + logger('Warn', 'The Forest server rejected the session refresh', { + ...sessionContext, + error: error.error, + errorDescription: error.message, + }); + throw sessionExpired('The Forest server rejected the refresh token'); } @@ -57,11 +79,10 @@ async function refreshAndPersist( return rotated.saasAccessToken; } -export default async function ensureFreshServerAccess({ - sid, - store, - serverClient, -}: EnsureFreshServerAccessParams): Promise { +export default async function ensureFreshServerAccess( + params: EnsureFreshServerAccessParams, +): Promise { + const { sid, store } = params; const session = store.get(sid); if (!session) { @@ -75,7 +96,7 @@ export default async function ensureFreshServerAccess({ const existing = inFlightRefreshesBySid.get(sid); if (existing) return existing; - const refresh = refreshAndPersist(sid, store, serverClient).finally(() => { + const refresh = refreshAndPersist(session, params).finally(() => { inFlightRefreshesBySid.delete(sid); }); inFlightRefreshesBySid.set(sid, refresh); diff --git a/packages/agent-bff/src/oauth/session-store.ts b/packages/agent-bff/src/oauth/session-store.ts index 5d32836350..e3bf423117 100644 --- a/packages/agent-bff/src/oauth/session-store.ts +++ b/packages/agent-bff/src/oauth/session-store.ts @@ -7,6 +7,7 @@ export interface CreateSessionInput { saasRefreshToken: string; renderingId: number; userId: number; + clientId: string; } export interface StoredSession { @@ -15,6 +16,7 @@ export interface StoredSession { refreshTokenHash: string; renderingId: number; userId: number; + clientId: string; expiresAt: number; } @@ -145,6 +147,7 @@ export default function createInMemorySessionStore({ refreshTokenHash, renderingId: input.renderingId, userId: input.userId, + clientId: input.clientId, expiresAt: now() + sessionTtlSeconds * 1000, }); activeRefreshHashToSid.set(refreshTokenHash, sid); diff --git a/packages/agent-bff/test/ai/ai-routes-middleware.test.ts b/packages/agent-bff/test/ai/ai-routes-middleware.test.ts index 331f8a9f64..88f4734b90 100644 --- a/packages/agent-bff/test/ai/ai-routes-middleware.test.ts +++ b/packages/agent-bff/test/ai/ai-routes-middleware.test.ts @@ -139,7 +139,10 @@ describe('createAiRoutesMiddleware', () => { describe('when refreshing the session fails on a transient error', () => { it('should answer 502 network_error rather than the oauth server_error type', async () => { - const store = makeSessionStore({ saasAccessToken: 'expired.token.value' }); + const store = makeSessionStore({ + saasAccessToken: 'expired.token.value', + clientId: 'client-1', + }); const serverClient = { refreshServerToken: jest.fn().mockRejectedValue(new Error('socket hang up')), } as unknown as ForestServerClient; @@ -158,7 +161,10 @@ describe('createAiRoutesMiddleware', () => { const transport = Object.assign(new TypeError('fetch failed'), { cause: new Error('connect ECONNREFUSED 10.0.0.1:443'), }); - const store = makeSessionStore({ saasAccessToken: 'expired.token.value' }); + const store = makeSessionStore({ + saasAccessToken: 'expired.token.value', + clientId: 'client-1', + }); const serverClient = { refreshServerToken: jest.fn().mockRejectedValue(transport), } as unknown as ForestServerClient; diff --git a/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts b/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts index 3bc3136bca..39c9016b95 100644 --- a/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts +++ b/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts @@ -111,7 +111,7 @@ describe('forest server token middleware', () => { it('should refuse with audit_unavailable when the Forest server cannot be reached', async () => { const store = { - get: () => ({ saasAccessToken: expiredAccessToken() }), + get: () => ({ saasAccessToken: expiredAccessToken(), clientId: 'client-1' }), getSaasRefreshToken: () => 'refresh-token', } as unknown as SessionStore; const serverClient = { @@ -160,7 +160,7 @@ describe('forest server token middleware', () => { it('should refuse with session_expired when the Forest server rejects the refresh token', async () => { const store = { - get: () => ({ saasAccessToken: expiredAccessToken() }), + get: () => ({ saasAccessToken: expiredAccessToken(), clientId: 'client-1' }), getSaasRefreshToken: () => 'refresh-token', } as unknown as SessionStore; const serverClient = { diff --git a/packages/agent-bff/test/oauth/fixtures/forestadmin-server-oauth-route-validator-issue-token.ts b/packages/agent-bff/test/oauth/fixtures/forestadmin-server-oauth-route-validator-issue-token.ts new file mode 100644 index 0000000000..e181ed45b6 --- /dev/null +++ b/packages/agent-bff/test/oauth/fixtures/forestadmin-server-oauth-route-validator-issue-token.ts @@ -0,0 +1,33 @@ +import Joi from 'joi'; + +const issueTokenBodySchema = Joi.object({ + client_id: Joi.string().required(), + grant_type: Joi.string().valid('authorization_code', 'refresh_token').required(), + code: Joi.string().when('grant_type', { + is: 'authorization_code', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), + redirect_uri: Joi.string().uri().when('grant_type', { + is: 'authorization_code', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), + code_verifier: Joi.string().when('grant_type', { + is: 'authorization_code', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), + refresh_token: Joi.string().when('grant_type', { + is: 'refresh_token', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), + scope: Joi.string().when('grant_type', { + is: 'refresh_token', + then: Joi.optional(), + otherwise: Joi.forbidden(), + }), +}).required(); + +export default issueTokenBodySchema; diff --git a/packages/agent-bff/test/oauth/forest-server-client.test.ts b/packages/agent-bff/test/oauth/forest-server-client.test.ts index c670be1e14..c1fb9e1a36 100644 --- a/packages/agent-bff/test/oauth/forest-server-client.test.ts +++ b/packages/agent-bff/test/oauth/forest-server-client.test.ts @@ -91,7 +91,7 @@ describe('ForestServerClient', () => { }); const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); - const tokens = await client.refreshServerToken('R1'); + const tokens = await client.refreshServerToken({ refreshToken: 'R1', clientId: 'client-1' }); expect(tokens.saasAccessToken).toBe(newAccess); expect(tokens.saasRefreshToken).toBe('R2'); @@ -103,7 +103,9 @@ describe('ForestServerClient', () => { const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); - await expect(client.refreshServerToken('R1')).rejects.toBeInstanceOf(OAuthExchangeError); + await expect( + client.refreshServerToken({ refreshToken: 'R1', clientId: 'client-1' }), + ).rejects.toBeInstanceOf(OAuthExchangeError); }); it('should not require renderingId in the refresh-grant access token', async () => { @@ -118,7 +120,9 @@ describe('ForestServerClient', () => { const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); - await expect(client.refreshServerToken('R1')).resolves.toMatchObject({ + await expect( + client.refreshServerToken({ refreshToken: 'R1', clientId: 'client-1' }), + ).resolves.toMatchObject({ saasRefreshToken: 'R2', }); }); @@ -202,7 +206,9 @@ describe('ForestServerClient', () => { const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); - await expect(client.refreshServerToken('R1')).resolves.toMatchObject({ renderingId: 0 }); + await expect( + client.refreshServerToken({ refreshToken: 'R1', clientId: 'client-1' }), + ).resolves.toMatchObject({ renderingId: 0 }); }); }); diff --git a/packages/agent-bff/test/oauth/forest-server-token-contract.test.ts b/packages/agent-bff/test/oauth/forest-server-token-contract.test.ts new file mode 100644 index 0000000000..6ebfae9766 --- /dev/null +++ b/packages/agent-bff/test/oauth/forest-server-token-contract.test.ts @@ -0,0 +1,65 @@ +import jsonwebtoken from 'jsonwebtoken'; + +import issueTokenBodySchema from './fixtures/forestadmin-server-oauth-route-validator-issue-token'; +import ForestServerClient from '../../src/oauth/forest-server-client'; + +jest.mock('@forestadmin/forestadmin-client', () => ({ + __esModule: true, + default: jest.fn(() => ({ authService: { getUserInfo: jest.fn() } })), +})); + +function captureTokenRequestBody(): jest.Mock { + const access = jsonwebtoken.sign( + { meta: { renderingId: 17 }, exp: Math.floor(Date.now() / 1000) + 3600 }, + 'irrelevant', + ); + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ access_token: access, refresh_token: 'R2' }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + return fetchMock; +} + +function sentBody(fetchMock: jest.Mock): unknown { + return JSON.parse(fetchMock.mock.calls[0][1].body); +} + +describe('Forest server /oauth/token contract', () => { + const client = new ForestServerClient({ + forestServerUrl: 'https://api.forestadmin.com', + envSecret: 'env-secret', + }); + + it('should send a refresh grant body the Forest server accepts', async () => { + const fetchMock = captureTokenRequestBody(); + + await client.refreshServerToken({ refreshToken: 'R1', clientId: 'client-1' }); + + expect(issueTokenBodySchema.validate(sentBody(fetchMock)).error).toBeUndefined(); + }); + + it('should send an authorization code body the Forest server accepts', async () => { + const fetchMock = captureTokenRequestBody(); + + await client.exchangeCode({ + code: 'code-1', + codeVerifier: 'verifier-1', + redirectUri: 'http://localhost/callback', + clientId: 'client-1', + }); + + expect(issueTokenBodySchema.validate(sentBody(fetchMock)).error).toBeUndefined(); + }); + + it('should be rejected by the Forest server schema when client_id is missing', () => { + const { error } = issueTokenBodySchema.validate({ + grant_type: 'refresh_token', + refresh_token: 'R1', + }); + + expect(error?.message).toBe('"client_id" is required'); + }); +}); diff --git a/packages/agent-bff/test/oauth/oauth-routes.test.ts b/packages/agent-bff/test/oauth/oauth-routes.test.ts index 2ceef09962..af997b01e6 100644 --- a/packages/agent-bff/test/oauth/oauth-routes.test.ts +++ b/packages/agent-bff/test/oauth/oauth-routes.test.ts @@ -370,6 +370,15 @@ describe('oauth-routes POST /oauth/token', () => { expect(JSON.stringify(logs)).not.toContain(SENTINEL_ACCESS); expect(JSON.stringify(logs)).not.toContain(SENTINEL_REFRESH); }); + + it('should store the exchanged client id in the session for later refreshes', async () => { + const { app, sessionStore } = buildApp(stubServerClient()); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + const { sid } = jsonwebtoken.decode(response.body.access_token) as { sid: string }; + + expect(sessionStore.get(sid)?.clientId).toBe(CLIENT_ID); + }); }); describe('when the redirect_uri does not match the registered client', () => { diff --git a/packages/agent-bff/test/oauth/session-lifecycle.test.ts b/packages/agent-bff/test/oauth/session-lifecycle.test.ts index 9542561944..ad8947233e 100644 --- a/packages/agent-bff/test/oauth/session-lifecycle.test.ts +++ b/packages/agent-bff/test/oauth/session-lifecycle.test.ts @@ -36,6 +36,12 @@ function rotatedTokens(): ServerTokens { } describe('ensureFreshServerAccess', () => { + const logger = jest.fn(); + + beforeEach(() => { + logger.mockReset(); + }); + describe('when the stored SaaS access token is still valid', () => { it('should return it without refreshing or writing to the store', async () => { const store = buildStore(); @@ -45,11 +51,12 @@ describe('ensureFreshServerAccess', () => { saasRefreshToken: 'R1', renderingId: 17, userId: 42, + clientId: 'client-1', }); const refreshServerToken = jest.fn(); const client = { refreshServerToken } as unknown as ForestServerClient; - const result = await ensureFreshServerAccess({ sid, store, serverClient: client }); + const result = await ensureFreshServerAccess({ sid, store, serverClient: client, logger }); expect(result).toBe(validAccess); expect(refreshServerToken).not.toHaveBeenCalled(); @@ -64,18 +71,67 @@ describe('ensureFreshServerAccess', () => { saasRefreshToken: 'R1', renderingId: 17, userId: 42, + clientId: 'client-1', }); const rotated = rotatedTokens(); const client = { refreshServerToken: jest.fn(async () => rotated), } as unknown as ForestServerClient; - const result = await ensureFreshServerAccess({ sid, store, serverClient: client }); + const result = await ensureFreshServerAccess({ sid, store, serverClient: client, logger }); expect(result).toBe(rotated.saasAccessToken); expect(store.get(sid)?.saasAccessToken).toBe(rotated.saasAccessToken); expect(store.getSaasRefreshToken(sid)).toBe('NEW-REFRESH'); }); + + it('should refresh with the client id stored at code exchange', async () => { + const store = buildStore(); + const { sid } = store.create({ + saasAccessToken: accessToken(-10), + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + clientId: 'client-1', + }); + const refreshServerToken = jest.fn(async () => rotatedTokens()); + const client = { refreshServerToken } as unknown as ForestServerClient; + + await ensureFreshServerAccess({ sid, store, serverClient: client, logger }); + + expect(refreshServerToken).toHaveBeenCalledWith({ refreshToken: 'R1', clientId: 'client-1' }); + }); + }); + + describe('when the session has no stored client id', () => { + it('should throw session_expired and log it, without calling the Forest server', async () => { + const expiredAccess = accessToken(-10); + const store: SessionStore = { + get: () => + ({ saasAccessToken: expiredAccess, renderingId: 17, userId: 42 } as StoredSession), + getSaasRefreshToken: () => 'R1', + create: jest.fn(), + updateSaasTokens: jest.fn(), + claimAuthorizationCode: jest.fn(), + releaseAuthorizationCode: jest.fn(), + prepareRotation: jest.fn(), + commitRotation: jest.fn(), + destroy: jest.fn(), + pendingClaimCount: jest.fn(), + }; + const refreshServerToken = jest.fn(); + const client = { refreshServerToken } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid: 'sid-1', store, serverClient: client, logger }), + ).rejects.toMatchObject({ type: 'session_expired' }); + expect(refreshServerToken).not.toHaveBeenCalled(); + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'The session has no client id to refresh the Forest server token', + { renderingId: 17, userId: 42 }, + ); + }); }); describe('when two concurrent requests hit an expired access token', () => { @@ -86,6 +142,7 @@ describe('ensureFreshServerAccess', () => { saasRefreshToken: 'R1', renderingId: 17, userId: 42, + clientId: 'client-1', }); const rotated = rotatedTokens(); const refreshServerToken = jest.fn(async () => { @@ -98,8 +155,8 @@ describe('ensureFreshServerAccess', () => { const client = { refreshServerToken } as unknown as ForestServerClient; const [a, b] = await Promise.all([ - ensureFreshServerAccess({ sid, store, serverClient: client }), - ensureFreshServerAccess({ sid, store, serverClient: client }), + ensureFreshServerAccess({ sid, store, serverClient: client, logger }), + ensureFreshServerAccess({ sid, store, serverClient: client, logger }), ]); expect(refreshServerToken).toHaveBeenCalledTimes(1); @@ -115,7 +172,7 @@ describe('ensureFreshServerAccess', () => { const client = { refreshServerToken } as unknown as ForestServerClient; await expect( - ensureFreshServerAccess({ sid: 'unknown-sid', store, serverClient: client }), + ensureFreshServerAccess({ sid: 'unknown-sid', store, serverClient: client, logger }), ).rejects.toMatchObject({ type: 'session_expired' }); expect(refreshServerToken).not.toHaveBeenCalled(); }); @@ -125,7 +182,7 @@ describe('ensureFreshServerAccess', () => { it('should throw a session_expired error', async () => { const expiredAccess = accessToken(-10); const store: SessionStore = { - get: () => ({ saasAccessToken: expiredAccess } as StoredSession), + get: () => ({ saasAccessToken: expiredAccess, clientId: 'client-1' } as StoredSession), getSaasRefreshToken: () => undefined, create: jest.fn(), updateSaasTokens: jest.fn(), @@ -140,7 +197,7 @@ describe('ensureFreshServerAccess', () => { const client = { refreshServerToken } as unknown as ForestServerClient; await expect( - ensureFreshServerAccess({ sid: 'sid-1', store, serverClient: client }), + ensureFreshServerAccess({ sid: 'sid-1', store, serverClient: client, logger }), ).rejects.toMatchObject({ type: 'session_expired' }); expect(refreshServerToken).not.toHaveBeenCalled(); }); @@ -151,7 +208,10 @@ describe('ensureFreshServerAccess', () => { const expiredAccess = accessToken(-10); const get = jest .fn() - .mockReturnValueOnce({ saasAccessToken: expiredAccess } as StoredSession) + .mockReturnValueOnce({ + saasAccessToken: expiredAccess, + clientId: 'client-1', + } as StoredSession) .mockReturnValue(undefined); const updateSaasTokens = jest.fn(); const store: SessionStore = { @@ -171,7 +231,7 @@ describe('ensureFreshServerAccess', () => { } as unknown as ForestServerClient; await expect( - ensureFreshServerAccess({ sid: 'sid-1', store, serverClient: client }), + ensureFreshServerAccess({ sid: 'sid-1', store, serverClient: client, logger }), ).rejects.toMatchObject({ type: 'session_expired' }); expect(updateSaasTokens).not.toHaveBeenCalled(); }); @@ -185,6 +245,7 @@ describe('ensureFreshServerAccess', () => { saasRefreshToken: 'R1', renderingId: 17, userId: 42, + clientId: 'client-1', }); const client = { refreshServerToken: jest.fn(async () => { @@ -193,7 +254,7 @@ describe('ensureFreshServerAccess', () => { } as unknown as ForestServerClient; await expect( - ensureFreshServerAccess({ sid, store, serverClient: client }), + ensureFreshServerAccess({ sid, store, serverClient: client, logger }), ).rejects.toMatchObject({ type: 'server_error' }); }); }); @@ -206,6 +267,7 @@ describe('ensureFreshServerAccess', () => { saasRefreshToken: 'R1', renderingId: 17, userId: 42, + clientId: 'client-1', }); const client = { refreshServerToken: jest.fn(async () => { @@ -214,8 +276,38 @@ describe('ensureFreshServerAccess', () => { } as unknown as ForestServerClient; await expect( - ensureFreshServerAccess({ sid, store, serverClient: client }), + ensureFreshServerAccess({ sid, store, serverClient: client, logger }), + ).rejects.toMatchObject({ type: 'session_expired' }); + }); + + it('should log the error code and description the Forest server sent', async () => { + const store = buildStore(); + const { sid } = store.create({ + saasAccessToken: accessToken(-10), + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + clientId: 'client-1', + }); + const client = { + refreshServerToken: jest.fn(async () => { + throw new OAuthExchangeError('invalid_request', '"client_id" is required'); + }), + } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid, store, serverClient: client, logger }), ).rejects.toMatchObject({ type: 'session_expired' }); + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'The Forest server rejected the session refresh', + { + renderingId: 17, + userId: 42, + error: 'invalid_request', + errorDescription: '"client_id" is required', + }, + ); }); }); }); diff --git a/packages/agent-bff/test/oauth/session-store.test.ts b/packages/agent-bff/test/oauth/session-store.test.ts index 276f6ebc0f..5ae3089370 100644 --- a/packages/agent-bff/test/oauth/session-store.test.ts +++ b/packages/agent-bff/test/oauth/session-store.test.ts @@ -14,6 +14,7 @@ const SESSION_INPUT = { saasRefreshToken: 'SAAS-REFRESH-SENTINEL', renderingId: 17, userId: 42, + clientId: 'client-1', }; describe('session-store', () => {