diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts index 7d0d4cb14899..642a1748df73 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts @@ -5,12 +5,13 @@ import jwt from 'jsonwebtoken' import request from 'supertest' import { setupTestServer } from '../../../../test/setupTestServer' import { - mockedTokensResponse as tokensResponse, - SID_VALUE, - SESSION_COOKIE_NAME, ALGORITM_TYPE, + SESSION_COOKIE_NAME, + SID_VALUE, getLoginSearchParmsFn, + mockedTokensResponse as tokensResponse, } from '../../../../test/sharedConstants' +import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { IdsService } from '../ids/ids.service' import { ParResponse } from '../ids/ids.types' @@ -58,17 +59,9 @@ const parResponse: ParResponse = { const allowedTargetLinkUri = 'http://test-client.com/testclient' const mockIdsService = { - getPar: jest.fn().mockResolvedValue({ - type: 'success', - data: parResponse, - }), - getTokens: jest.fn().mockResolvedValue({ - type: 'success', - data: tokensResponse, - }), - revokeToken: jest.fn().mockResolvedValue({ - type: 'success', - }), + getPar: jest.fn().mockResolvedValue(parResponse), + getTokens: jest.fn().mockResolvedValue(tokensResponse), + revokeToken: jest.fn().mockResolvedValue(undefined), getLoginSearchParams: jest.fn().mockImplementation(getLoginSearchParmsFn), } @@ -89,7 +82,7 @@ describe('AuthController', () => { }) mockConfig = app.get>(BffConfig.KEY) - baseUrlWithKey = `${mockConfig.clientBaseUrl}${process.env.BFF_CLIENT_KEY_PATH}` + baseUrlWithKey = `${mockConfig.clientBaseUrl}${environment.keyPath}` server = request(app.getHttpServer()) }) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 0c7a36f31750..b3472b86f688 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -9,7 +9,7 @@ import { UnauthorizedException, } from '@nestjs/common' import { ConfigType } from '@nestjs/config' -import { CookieOptions, Request, Response } from 'express' +import type { Request, Response } from 'express' import jwksClient from 'jwks-rsa' import { jwtDecode } from 'jwt-decode' @@ -26,6 +26,7 @@ import { CreateErrorQueryStrArgs, createErrorQueryStr, } from '../../utils/create-error-query-str' +import { getCookieOptions } from '../../utils/get-cookie-options' import { validateUri } from '../../utils/validate-uri' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' @@ -55,17 +56,6 @@ export class AuthService { this.baseUrl = this.config.ids.issuer } - private getCookieOptions(): CookieOptions { - return { - httpOnly: true, - secure: true, - // The lax setting allows cookies to be sent on top-level navigations (such as redirects), - // while still providing some protection against CSRF attacks. - sameSite: 'lax', - path: environment.keyPath, - } - } - /** * Creates the client base URL with the path appended. */ @@ -212,12 +202,8 @@ export class AuthService { prompt, }) - if (parResponse.type === 'error') { - throw parResponse.data - } - searchParams = new URLSearchParams({ - request_uri: parResponse.data.request_uri, + request_uri: parResponse.request_uri, client_id: this.config.ids.clientId, }) } else { @@ -297,13 +283,7 @@ export class AuthService { codeVerifier: loginAttemptData.codeVerifier, }) - if (tokenResponse.type === 'error') { - throw tokenResponse.data - } - - const updatedTokenResponse = await this.updateTokenCache( - tokenResponse.data, - ) + const updatedTokenResponse = await this.updateTokenCache(tokenResponse) // Clean up the login attempt from the cache since we have a successful login. this.cacheService @@ -312,11 +292,15 @@ export class AuthService { this.logger.warn(err) }) + // Clear any existing session cookie first + // This prevents multiple session cookies being set. + res.clearCookie(SESSION_COOKIE_NAME, getCookieOptions()) + // Create session cookie with successful login session id res.cookie( SESSION_COOKIE_NAME, updatedTokenResponse.userProfile.sid, - this.getCookieOptions(), + getCookieOptions(), ) // Check if there is an old session cookie and clean up the cache @@ -424,7 +408,7 @@ export class AuthService { * - Delete the current login from the cache * - Clear the session cookie */ - res.clearCookie(SESSION_COOKIE_NAME, this.getCookieOptions()) + res.clearCookie(SESSION_COOKIE_NAME, getCookieOptions()) this.cacheService .delete(currentLoginCacheKey) diff --git a/apps/services/bff/src/app/modules/auth/token-refresh.service.spec.ts b/apps/services/bff/src/app/modules/auth/token-refresh.service.spec.ts new file mode 100644 index 000000000000..8b1dc46546e5 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/token-refresh.service.spec.ts @@ -0,0 +1,258 @@ +import { LOGGER_PROVIDER } from '@island.is/logging' +import { Test } from '@nestjs/testing' +import { CacheService } from '../cache/cache.service' +import { IdsService } from '../ids/ids.service' +import { TokenResponse } from '../ids/ids.types' +import { AuthService } from './auth.service' +import { CachedTokenResponse } from './auth.types' +import { TokenRefreshService } from './token-refresh.service' + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('fake_uuid'), +})) + +const mockLogger = { + error: jest.fn(), + warn: jest.fn(), +} + +const mockCacheStore = new Map() + +const mockTokenResponse: CachedTokenResponse = { + id_token: 'mock.id.token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'openid profile offline_access', + scopes: ['openid', 'profile', 'offline_access'], + userProfile: { + sid: 'test-session-id', + nationalId: '1234567890', + name: 'Test User', + idp: 'test-idp', + subjectType: 'person', + delegationType: [], + locale: 'is', + birthdate: '1990-01-01', + }, + accessTokenExp: Date.now() + 3600000, // Current time + 1 hour in milliseconds + encryptedAccessToken: 'encrypted.access.token', + encryptedRefreshToken: 'encrypted.refresh.token', +} + +// When mocking IdsService.refreshToken response, we need TokenResponse type: +const mockIdsTokenResponse: TokenResponse = { + id_token: 'mock.id.token', + access_token: 'mock.access.token', + refresh_token: 'mock.refresh.token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'openid profile offline_access', +} + +describe('TokenRefreshService', () => { + let service: TokenRefreshService + let authService: AuthService + let idsService: IdsService + let cacheService: CacheService + const testSid = 'test-sid' + const testRefreshToken = 'test-refresh-token' + const refreshInProgressPrefix = 'refresh_token_in_progress' + const refreshInProgressKey = `${refreshInProgressPrefix}:${testSid}` + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + TokenRefreshService, + { + provide: LOGGER_PROVIDER, + useValue: mockLogger, + }, + { + provide: AuthService, + useValue: { + updateTokenCache: jest.fn().mockResolvedValue(mockTokenResponse), + }, + }, + { + provide: IdsService, + useValue: { + refreshToken: jest.fn().mockResolvedValue(mockTokenResponse), + }, + }, + { + provide: CacheService, + useValue: { + save: jest.fn().mockImplementation(async ({ key, value }) => { + mockCacheStore.set(key, value) + }), + get: jest + .fn() + .mockImplementation(async (key) => mockCacheStore.get(key)), + delete: jest + .fn() + .mockImplementation(async (key) => mockCacheStore.delete(key)), + createSessionKeyType: jest.fn((type, sid) => `${type}_${sid}`), + }, + }, + ], + }).compile() + + service = module.get(TokenRefreshService) + authService = module.get(AuthService) + idsService = module.get(IdsService) + cacheService = module.get(CacheService) + }) + + afterEach(() => { + mockCacheStore.clear() + jest.clearAllMocks() + }) + + describe('refreshToken', () => { + it('should successfully refresh token when no refresh is in progress', async () => { + // Act + const result = await service.refreshToken({ + sid: testSid, + encryptedRefreshToken: testRefreshToken, + }) + + // Assert + expect(idsService.refreshToken).toHaveBeenCalledWith(testRefreshToken) + expect(authService.updateTokenCache).toHaveBeenCalledWith( + mockTokenResponse, + ) + expect(result).toEqual(mockTokenResponse) + }) + + it('should wait for ongoing refresh and return cached result', async () => { + // Arrange + await cacheService.save({ + key: refreshInProgressKey, + value: true, + ttl: 3000, + }) + + // Simulate another service updating the token while we wait + setTimeout(async () => { + await cacheService.delete(refreshInProgressKey) + await cacheService.save({ + key: `current_${testSid}`, + value: mockTokenResponse, + ttl: 3600, + }) + }, 500) + + // Act + const result = await service.refreshToken({ + sid: testSid, + encryptedRefreshToken: testRefreshToken, + }) + + // Assert + expect(result).toEqual(mockTokenResponse) + expect(idsService.refreshToken).not.toHaveBeenCalled() + }) + + it('should retry refresh if polling times out', async () => { + // Arrange + await cacheService.save({ + key: refreshInProgressKey, + value: true, + ttl: 3000, + }) + + // Act + const result = await service.refreshToken({ + sid: testSid, + encryptedRefreshToken: testRefreshToken, + }) + + // Assert + expect(mockLogger.warn).toHaveBeenCalled() + expect(idsService.refreshToken).toHaveBeenCalledWith(testRefreshToken) + expect(result).toEqual(mockTokenResponse) + }) + + it('should handle refresh token failure', async () => { + // Arrange + const error = new Error('Refresh token failed') + jest.spyOn(idsService, 'refreshToken').mockRejectedValueOnce(error) + + // Act + const cachedTokenResponse = await service.refreshToken({ + sid: testSid, + encryptedRefreshToken: testRefreshToken, + }) + // + expect(cachedTokenResponse).toBe(null) + + expect(mockLogger.warn).toHaveBeenCalledWith( + `Token refresh failed for sid: ${testSid}`, + ) + }) + + it('should prevent concurrent refresh token requests', async () => { + // Arrange + const refreshPromises = [] + const refreshCount = 5 + let firstRequestStarted = false + + // Mock cache.get to make sure first request get in progress lock and other requests waits + jest.spyOn(cacheService, 'get').mockImplementation(async (key) => { + if (key.includes(refreshInProgressPrefix)) { + return firstRequestStarted + } + return mockTokenResponse + }) + + // Mock cache.save to track first request + jest.spyOn(cacheService, 'save').mockImplementation(async ({ key }) => { + if (key.includes(refreshInProgressPrefix)) { + firstRequestStarted = true + // Add delay after setting lock + await delay(50) + } + }) + + // Mock cache.delete to clear the lock + jest.spyOn(cacheService, 'delete').mockImplementation(async (key) => { + if (key.includes(refreshInProgressPrefix)) { + firstRequestStarted = false + } + }) + + // Act + // First request + refreshPromises.push( + service.refreshToken({ + sid: testSid, + encryptedRefreshToken: testRefreshToken, + }), + ) + + // Wait a tick to ensure first request starts + await delay(10) + + // Remaining requests + for (let i = 1; i < refreshCount; i++) { + refreshPromises.push( + service.refreshToken({ + sid: testSid, + encryptedRefreshToken: testRefreshToken, + }), + ) + } + + // Wait for all promises to resolve + const results = await Promise.all(refreshPromises) + + // Assert + expect(idsService.refreshToken).toHaveBeenCalledTimes(1) + results.forEach((result) => { + expect(result).toEqual(mockTokenResponse) + }) + }) + }) +}) diff --git a/apps/services/bff/src/app/modules/auth/token-refresh.service.ts b/apps/services/bff/src/app/modules/auth/token-refresh.service.ts new file mode 100644 index 000000000000..0af2bac09fb5 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/token-refresh.service.ts @@ -0,0 +1,223 @@ +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' +import { Inject, Injectable } from '@nestjs/common' +import { hasTimestampExpiredInMS } from '../../utils/has-timestamp-expired-in-ms' +import { CacheService } from '../cache/cache.service' +import { IdsService } from '../ids/ids.service' +import { AuthService } from './auth.service' +import { CachedTokenResponse } from './auth.types' + +/** + * Service responsible for handling token refresh operations + * Provides concurrent request protection and token refresh polling + */ +@Injectable() +export class TokenRefreshService { + private static POLL_INTERVAL = 200 // ms + private static MAX_POLL_TIME = 3000 // 3 seconds + + constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, + private readonly authService: AuthService, + private readonly cacheService: CacheService, + private readonly idsService: IdsService, + ) {} + + private delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + /** + * Creates a unique key for tracking refresh token operations in progress + * This key is used to prevent concurrent refresh token requests for the same session + * + * @param sid - Session ID + * @returns Formatted key string for refresh token tracking + */ + private createRefreshTokenKey(sid: string): string { + return `refresh_token_in_progress:${sid}` + } + + /** + * Creates a key for storing token response data in cache + * This key is used to store and retrieve the current token data for a session + * + * @param sid - Session ID + * @returns Formatted key string for token response data + */ + private createTokenResponseKey(sid: string): string { + return this.cacheService.createSessionKeyType('current', sid) + } + + /** + * Executes the token refresh operation and updates the cache + * This method: + * 1. Sets a flag in cache to indicate refresh is in progress + * 2. Requests new tokens from the identity server + * 3. Updates the cache with the new token data + * 4. Cleans up the refresh flag + * + * @param params.refreshTokenKey - Redis key for tracking refresh status + * @param params.encryptedRefreshToken - Encrypted refresh token for getting new tokens + * + * @returns Promise Updated token data + * @throws Will throw if token refresh fails or cache operations fail + */ + private async executeTokenRefresh({ + refreshTokenKey, + encryptedRefreshToken, + }: { + refreshTokenKey: string + encryptedRefreshToken: string + }): Promise { + let tokenResponse: CachedTokenResponse | null = null + + try { + // Set refresh in progress + await this.cacheService.save({ + key: refreshTokenKey, + value: true, + ttl: TokenRefreshService.MAX_POLL_TIME, + }) + + const newTokens = await this.idsService.refreshToken( + encryptedRefreshToken, + ) + tokenResponse = await this.authService.updateTokenCache(newTokens) + } catch (error) { + this.logger.warn('Failed to refresh tokens: ', error) + } finally { + await this.cacheService.delete(refreshTokenKey) + } + + return tokenResponse + } + + /** + * Waits for an ongoing refresh operation to complete + * Uses polling with a maximum wait time + * + * @param sid Session ID + */ + private async waitForRefreshCompletion(sid: string): Promise { + let attempts = 0 + // Calculate how many attempts we should make + // maxAttempts = 3000 / 200 = ~15 ish attempts + const maxAttempts = + TokenRefreshService.MAX_POLL_TIME / TokenRefreshService.POLL_INTERVAL + + while (attempts < maxAttempts) { + const refreshTokenInProgress = await this.cacheService.get( + this.createRefreshTokenKey(sid), + false, + ) + + // If refresh is no longer in progress, we're done + if (!refreshTokenInProgress) { + return true + } + + // Wait for for POLL_INTERVAL before next attempt + await this.delay(TokenRefreshService.POLL_INTERVAL) + + attempts++ + } + + // We've made all attempts (~15 attempts in 3 seconds total) and still no success + this.logger.warn( + `Polling timed out for token refresh completion for session ${sid}`, + ) + + return false + } + + /** + * Retrieves and validates token from cache + * Checks for existence and expiration + * + * @param tokenResponseKey - Key in cache service for token response data + */ + private async getTokenFromCache( + tokenResponseKey: string, + ): Promise { + const tokenResponse = await this.cacheService.get( + tokenResponseKey, + false, + ) + + if (!tokenResponse) { + this.logger.warn('No token response found in cache') + + return null + } + + if (hasTimestampExpiredInMS(tokenResponse.accessTokenExp)) { + this.logger.warn('Cached token has expired') + + return null + } + + return tokenResponse + } + + /** + * Handles the complete token refresh process with concurrent request protection + * This method: + * + * 1. Checks if a refresh is already in progress + * 2. If yes, waits for it to complete + * 3. If no, initiates a new refresh + * 4. Updates the cache with new token data + * 5. Cleans up tracking flags + * + * @param params.sid - Session ID + * @param params.encryptedRefreshToken - Encrypted refresh token to use for getting new tokens + * + * @returns Promise resolving to updated token response data + * @throws Forwards any errors from the refresh process after logging + */ + + public async refreshToken({ + sid, + encryptedRefreshToken, + }: { + sid: string + encryptedRefreshToken: string + }): Promise { + const refreshTokenKey = this.createRefreshTokenKey(sid) + const tokenResponseKey = this.createTokenResponseKey(sid) + + // Check if refresh is already in progress + const refreshTokenInProgress = await this.cacheService.get( + refreshTokenKey, + false, + ) + + if (refreshTokenInProgress) { + const refreshCompleted = await this.waitForRefreshCompletion(sid) + + if (refreshCompleted) { + const cachedToken = await this.getTokenFromCache(tokenResponseKey) + + if (cachedToken) { + return cachedToken + } + } + + // If waiting failed or no valid token found, proceed with new refresh + this.logger.warn('Retrying token refresh after failed wait') + } + + const updatedTokenResponse = await this.executeTokenRefresh({ + refreshTokenKey, + encryptedRefreshToken, + }) + + if (!updatedTokenResponse) { + this.logger.warn(`Token refresh failed for sid: ${sid}`) + } + + return updatedTokenResponse + } +} diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 494c40550a02..6b84856e9b70 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -6,8 +6,6 @@ import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' import { ENHANCED_FETCH_PROVIDER_KEY } from '../enhancedFetch/enhanced-fetch.provider' import { - ApiResponse, - ErrorRes, GetLoginSearchParamsReturnValue, ParResponse, TokenResponse, @@ -35,60 +33,28 @@ export class IdsService { private async postRequest( endpoint: string, body: Record, - ): Promise> { - try { - const response = await this.enhancedFetch( - `${this.issuerUrl}${endpoint}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: this.createPARAuthorizationHeader(), - }, - body: new URLSearchParams(body).toString(), - }, - ) - - const contentType = response.headers.get('content-type') || '' - - if (contentType.includes('application/json')) { - const data = await response.json() - - if (!response.ok) { - // If error response from Ids is not in the expected format, throw the data as is - if (!data.error || !data.error_description) { - throw data - } - - return { - type: 'error', - data: { - error: data.error, - error_description: data.error_description, - }, - } as ErrorRes - } - - return { - type: 'success', - data: data as T, - } - } - - // Handle plain text responses - const textResponse = await response.text() - - if (!response.ok) { - throw textResponse - } - - return { - type: 'success', - data: textResponse, - } as ApiResponse - } catch (error) { - throw new Error(error) + ): Promise { + const response = await this.enhancedFetch(`${this.issuerUrl}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: this.createPARAuthorizationHeader(), + }, + body: new URLSearchParams(body).toString(), + }) + + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes('application/json')) { + const data = await response.json() + + return data } + + // Handle plain text responses + const textResponse = await response.text() + + return textResponse as T } public getLoginSearchParams({ @@ -103,6 +69,7 @@ export class IdsService { prompt?: string }): GetLoginSearchParamsReturnValue { const { ids } = this.config + return { client_id: ids.clientId, redirect_uri: this.config.callbacksRedirectUris.login, diff --git a/apps/services/bff/src/app/modules/ids/ids.types.ts b/apps/services/bff/src/app/modules/ids/ids.types.ts index 8f88ced6f832..9b353df763a1 100644 --- a/apps/services/bff/src/app/modules/ids/ids.types.ts +++ b/apps/services/bff/src/app/modules/ids/ids.types.ts @@ -47,18 +47,6 @@ export type TokenResponse = { scope: string } -export interface SuccessResponse { - type: 'success' - data: T -} - -export interface ErrorRes { - type: 'error' - data: ErrorResponse -} - -export type ApiResponse = SuccessResponse | ErrorRes - export type LogoutTokenPayload = { // Issuer of the token iss: string diff --git a/apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts b/apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts index 1e97fcbe39c8..c5d4ba8e89e3 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts @@ -39,17 +39,9 @@ const EXPIRED_TOKEN_RESPONSE: TokenResponse = { } const mockIdsService = { - refreshToken: jest.fn().mockResolvedValue({ - type: 'success', - data: tokensResponse, - }), - getTokens: jest.fn().mockResolvedValue({ - type: 'success', - data: tokensResponse, - }), - revokeToken: jest.fn().mockResolvedValue({ - type: 'success', - }), + refreshToken: jest.fn().mockResolvedValue(tokensResponse), + getTokens: jest.fn().mockResolvedValue(tokensResponse), + revokeToken: jest.fn().mockResolvedValue(undefined), getLoginSearchParams: jest.fn().mockImplementation(getLoginSearchParmsFn), } @@ -160,10 +152,7 @@ describe('ProxyController', () => { describe('GET /api/graphql', () => { beforeEach(() => { jest.clearAllMocks() - mockIdsService.getTokens.mockResolvedValue({ - type: 'success', - data: tokensResponse, - }) + mockIdsService.getTokens.mockResolvedValue(tokensResponse) }) it('should throw 401 unauthorized when not logged in', async () => { @@ -222,10 +211,41 @@ describe('ProxyController', () => { it('should call refreshToken and cache token response when access_token is expired', async () => { // Arrange - mockIdsService.getTokens.mockResolvedValue({ - type: 'success', - data: EXPIRED_TOKEN_RESPONSE, + mockIdsService.getTokens.mockResolvedValue(EXPIRED_TOKEN_RESPONSE) + + // Act + await server.get('/login') + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .post('/api/graphql') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(mockIdsService.refreshToken).toHaveBeenCalled() + expect(res.status).toEqual(HttpStatus.OK) + }) + + it('should handle polling timeout and retry', async () => { + // Arrange + const encryptedTokens = { + encryptedAccessToken: 'encrypted_access_token', + encryptedRefreshToken: 'encrypted_refresh_token', + accessTokenExp: Date.now(), + } + + mockIdsService.getTokens.mockResolvedValue(EXPIRED_TOKEN_RESPONSE) + mockIdsService.refreshToken.mockResolvedValue(tokensResponse) + + // Set up initial cache state + mockCacheStore.set(`current_${SID_VALUE}`, { + ...EXPIRED_TOKEN_RESPONSE, + ...encryptedTokens, }) + mockCacheStore.set(`refresh_token_in_progress:${SID_VALUE}`, true) // Act await server.get('/login') @@ -241,6 +261,53 @@ describe('ProxyController', () => { // Assert expect(mockIdsService.refreshToken).toHaveBeenCalled() expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toEqual({ message: 'success' }) + }) + + it('should handle polling and not call ids refresh token', async () => { + // Arrange + const encryptedTokens = { + encryptedAccessToken: 'encrypted_access_token', + encryptedRefreshToken: 'encrypted_refresh_token', + accessTokenExp: Date.now() + 3600000, // Valid for 1 hour + } + + mockIdsService.getTokens.mockResolvedValue({ + ...tokensResponse, + ...encryptedTokens, + }) + + // Set up initial cache state + mockCacheStore.set(`current_${SID_VALUE}`, { + ...tokensResponse, + ...encryptedTokens, + }) + mockCacheStore.set(`refresh_token_in_progress:${SID_VALUE}`, true) + + // Simulate another service completing the refresh + setTimeout(() => { + mockCacheStore.delete(`refresh_token_in_progress:${SID_VALUE}`) + mockCacheStore.set(`current_${SID_VALUE}`, { + ...tokensResponse, + ...encryptedTokens, + }) + }, 100) + + // Act + await server.get('/login') + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .post('/api/graphql') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(mockIdsService.refreshToken).not.toHaveBeenCalled() + expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toEqual({ message: 'success' }) }) }) }) diff --git a/apps/services/bff/src/app/modules/proxy/proxy.module.ts b/apps/services/bff/src/app/modules/proxy/proxy.module.ts index d767e6c014d6..6cbeba5c5733 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.module.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.module.ts @@ -1,13 +1,21 @@ import { Module } from '@nestjs/common' +import { CryptoService } from '../../services/crypto.service' +import { ErrorService } from '../../services/error.service' import { AuthModule } from '../auth/auth.module' +import { TokenRefreshService } from '../auth/token-refresh.service' import { IdsService } from '../ids/ids.service' import { ProxyController } from './proxy.controller' import { ProxyService } from './proxy.service' -import { CryptoService } from '../../services/crypto.service' @Module({ imports: [AuthModule], controllers: [ProxyController], - providers: [ProxyService, IdsService, CryptoService], + providers: [ + ProxyService, + CryptoService, + TokenRefreshService, + ErrorService, + IdsService, + ], }) export class ProxyModule {} diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index e075b0e64c71..0f7244ca7de6 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -2,13 +2,14 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, + HttpStatus, Inject, Injectable, UnauthorizedException, } from '@nestjs/common' import { ConfigType } from '@nestjs/config' import AgentKeepAlive, { HttpsAgent } from 'agentkeepalive' -import { Request, Response } from 'express' +import type { Request, Response } from 'express' import fetch from 'node-fetch' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' @@ -17,12 +18,13 @@ import { AGENT_DEFAULT_FREE_SOCKET_TIMEOUT, AGENT_DEFAULT_MAX_SOCKETS, } from '@island.is/shared/constants' +import { SESSION_COOKIE_NAME } from '../../constants/cookies' +import { ErrorService } from '../../services/error.service' import { hasTimestampExpiredInMS } from '../../utils/has-timestamp-expired-in-ms' import { validateUri } from '../../utils/validate-uri' -import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' +import { TokenRefreshService } from '../auth/token-refresh.service' import { CacheService } from '../cache/cache.service' -import { IdsService } from '../ids/ids.service' import { ApiProxyDto } from './dto/api-proxy.dto' const droppedResponseHeaders = [ @@ -41,6 +43,7 @@ const agentOptions: AgentKeepAlive.HttpOptions = { freeSocketTimeout: AGENT_DEFAULT_FREE_SOCKET_TIMEOUT, maxSockets: AGENT_DEFAULT_MAX_SOCKETS, } + const customAgent = new AgentKeepAlive(agentOptions) /** * Only applies to none same domain requests. @@ -63,9 +66,9 @@ export class ProxyService { private readonly config: ConfigType, private readonly cacheService: CacheService, - private readonly idsService: IdsService, - private readonly authService: AuthService, private readonly cryptoService: CryptoService, + private readonly tokenRefreshService: TokenRefreshService, + private readonly errorService: ErrorService, ) {} /** @@ -73,47 +76,62 @@ export class ProxyService { * - If the token is expired, it will attempt to update tokens with the refresh token from cache. * - Then access token is decrypted and returned. */ - private async getAccessToken(req: Request) { - const sid = req.cookies['sid'] + private async getAccessToken({ + req, + res, + }: { + req: Request + res: Response + }): Promise { + const sid = req.cookies[SESSION_COOKIE_NAME] if (!sid) { throw new UnauthorizedException() } + const tokenResponseKey = this.cacheService.createSessionKeyType( + 'current', + sid, + ) + try { - let cachedTokenResponse = + let cachedTokenResponse: CachedTokenResponse | null = await this.cacheService.get( - this.cacheService.createSessionKeyType('current', sid), - ) - - if (hasTimestampExpiredInMS(cachedTokenResponse.accessTokenExp)) { - const tokenResponse = await this.idsService.refreshToken( - cachedTokenResponse.encryptedRefreshToken, + tokenResponseKey, + false, ) - if (tokenResponse.type === 'error') { - throw tokenResponse.data - } + if ( + cachedTokenResponse && + hasTimestampExpiredInMS(cachedTokenResponse.accessTokenExp) + ) { + cachedTokenResponse = await this.tokenRefreshService.refreshToken({ + sid, + encryptedRefreshToken: cachedTokenResponse.encryptedRefreshToken, + }) + } - cachedTokenResponse = await this.authService.updateTokenCache( - tokenResponse.data, - ) + if (!cachedTokenResponse) { + throw new UnauthorizedException() } return this.cryptoService.decrypt( cachedTokenResponse.encryptedAccessToken, ) } catch (error) { - this.logger.error('Error getting access token:', error) - - throw new UnauthorizedException() + return this.errorService.handleAuthorizedError({ + error, + res, + tokenResponseKey, + operation: `${ProxyService.name}.getAccessToken`, + }) } } /** * This method proxies the request to the target URL and streams the response back to the client. */ - async executeStreamRequest({ + public async executeStreamRequest({ targetUrl, accessToken, req, @@ -177,7 +195,9 @@ export class ProxyService { } catch (error) { this.logger.error('Error during proxy request processing: ', error) - res.status(error.status || 500).send('Failed to proxy request') + res + .status(error.status || HttpStatus.INTERNAL_SERVER_ERROR) + .send('Failed to proxy request') } } @@ -192,7 +212,7 @@ export class ProxyService { req: Request res: Response }): Promise { - const accessToken = await this.getAccessToken(req) + const accessToken = await this.getAccessToken({ req, res }) const queryString = req.url.split('?')[1] const targetUrl = `${this.config.graphqlApiEndpoint}${ queryString ? `?${queryString}` : '' @@ -210,7 +230,7 @@ export class ProxyService { * Forwards an incoming HTTP GET request to the specified URL (provided in the query string), * managing authentication, refreshing tokens if needed, and streaming the response back to the client. */ - async forwardGetApiRequest({ + public async forwardGetApiRequest({ req, res, query, @@ -218,16 +238,16 @@ export class ProxyService { req: Request res: Response query: ApiProxyDto - }) { + }): Promise { const { url } = query if (!validateUri(url, this.config.allowedExternalApiUrls)) { this.logger.error('Invalid external api url provided:', url) - throw new BadRequestException('Proxing url failed!') + throw new BadRequestException('Proxying url failed!') } - const accessToken = await this.getAccessToken(req) + const accessToken = await this.getAccessToken({ req, res }) this.executeStreamRequest({ accessToken, diff --git a/apps/services/bff/src/app/modules/user/user.controller.spec.ts b/apps/services/bff/src/app/modules/user/user.controller.spec.ts new file mode 100644 index 000000000000..4a2ca620d94b --- /dev/null +++ b/apps/services/bff/src/app/modules/user/user.controller.spec.ts @@ -0,0 +1,220 @@ +import { ConfigType } from '@island.is/nest/config' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus, INestApplication } from '@nestjs/common' +import request from 'supertest' + +import { setupTestServer } from '../../../../test/setupTestServer' +import { + SESSION_COOKIE_NAME, + SID_VALUE, + getLoginSearchParmsFn, + mockedTokensResponse as tokensResponse, +} from '../../../../test/sharedConstants' +import { environment } from '../../../environment' +import { BffConfig } from '../../bff.config' +import { TokenRefreshService } from '../auth/token-refresh.service' +import { IdsService } from '../ids/ids.service' + +const mockCacheStore = new Map() + +const mockCacheManagerValue = { + set: jest.fn((key, value) => mockCacheStore.set(key, value)), + get: jest.fn((key) => mockCacheStore.get(key)), + del: jest.fn((key) => mockCacheStore.delete(key)), +} + +const generateTokenTimestamps = (baseTime = 1700000000) => { + const now = baseTime + + return { + iat: now, + exp: now + 3600, + } +} + +const { iat, exp } = generateTokenTimestamps() + +const mockUserProfile = { + sub: '1234567890', + iss: 'https://example.com', + sid: SID_VALUE, + iat, + exp, +} + +const mockCachedTokenResponse = { + ...tokensResponse, + scopes: ['openid', 'profile', 'email'], + userProfile: mockUserProfile, + accessTokenExp: Date.now() + 3600000, + encryptedAccessToken: 'encrypted.access.token', + encryptedRefreshToken: 'encrypted.refresh.token', +} + +const mockTokenRefreshService = { + refreshToken: jest.fn().mockResolvedValue(mockCachedTokenResponse), +} + +const mockIdsService = { + getTokens: jest.fn().mockResolvedValue(mockCachedTokenResponse), + revokeToken: jest.fn().mockResolvedValue(undefined), + getLoginSearchParams: jest.fn().mockImplementation(getLoginSearchParmsFn), +} + +const createLoginAttempt = (mockConfig: ConfigType) => ({ + originUrl: `${mockConfig.clientBaseUrl}${environment.keyPath}`, + codeVerifier: 'test_code_verifier', + targetLinkUri: undefined, +}) + +describe('UserController', () => { + let app: INestApplication + let server: request.SuperTest + let mockConfig: ConfigType + + beforeAll(async () => { + app = await setupTestServer({ + override: (builder) => + builder + .overrideProvider(CACHE_MANAGER) + .useValue(mockCacheManagerValue) + .overrideProvider(TokenRefreshService) + .useValue(mockTokenRefreshService) + .overrideProvider(IdsService) + .useValue(mockIdsService), + }) + + mockConfig = app.get>(BffConfig.KEY) + server = request(app.getHttpServer()) + }) + + afterEach(() => { + mockCacheStore.clear() + jest.clearAllMocks() + }) + + afterAll(async () => { + if (app) { + await app.close() + } + }) + + describe('GET /user', () => { + it('should throw unauthorized exception when sid cookie is not provided', async () => { + // Act + const res = await server.get('/user') + + // Assert + expect(res.status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('should throw unauthorized exception when cache key is not found', async () => { + // Act + const res = await server + .get('/user') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(res.status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('should return user data when valid session exists', async () => { + // Arrange - Set up login attempt in cache + mockCacheStore.set(`attempt_${SID_VALUE}`, createLoginAttempt(mockConfig)) + + // Initialize session with login + await server.get('/login') + const callbackRes = await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + expect(callbackRes.status).toBe(HttpStatus.FOUND) + + // Act - Get user data + const res = await server + .get('/user') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toEqual({ + scopes: ['openid', 'profile', 'email'], + profile: expect.objectContaining({ + ...mockUserProfile, + iat: expect.any(Number), + exp: expect.any(Number), + }), + }) + }) + + it('should refresh token when access token is expired and refresh=true', async () => { + // Arrange - Set up login attempt in cache + mockCacheStore.set(`attempt_${SID_VALUE}`, createLoginAttempt(mockConfig)) + + // Initialize session + await server.get('/login') + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + // Set expired token in cache + const expiredTokenResponse = { + ...mockCachedTokenResponse, + accessTokenExp: Date.now() - 1000, // Expired token + } + mockCacheStore.set(`current_${SID_VALUE}`, expiredTokenResponse) + + // Act + const res = await server + .get('/user') + .query({ refresh: 'true' }) + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(mockTokenRefreshService.refreshToken).toHaveBeenCalledWith({ + sid: SID_VALUE, + encryptedRefreshToken: expiredTokenResponse.encryptedRefreshToken, + }) + expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toEqual({ + scopes: mockCachedTokenResponse.scopes, + profile: mockCachedTokenResponse.userProfile, + }) + }) + + it('should not refresh token when access token is expired but refresh=false', async () => { + // Arrange - Set up login attempt in cache + mockCacheStore.set(`attempt_${SID_VALUE}`, createLoginAttempt(mockConfig)) + + // Initialize session + await server.get('/login') + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + // Set expired token in cache + const expiredTokenResponse = { + ...mockCachedTokenResponse, + accessTokenExp: Date.now() - 1000, // Expired token + } + mockCacheStore.set(`current_${SID_VALUE}`, expiredTokenResponse) + + // Act + const res = await server + .get('/user') + .query({ refresh: 'false' }) + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(mockTokenRefreshService.refreshToken).not.toHaveBeenCalled() + expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toEqual({ + scopes: expiredTokenResponse.scopes, + profile: expiredTokenResponse.userProfile, + }) + }) + }) +}) diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts index 2e3045db2b67..9cb9e6e173b4 100644 --- a/apps/services/bff/src/app/modules/user/user.controller.ts +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -1,7 +1,14 @@ -import type { Request } from 'express' +import type { Request, Response } from 'express' import type { BffUser } from '@island.is/shared/types' -import { Controller, Get, Query, Req, VERSION_NEUTRAL } from '@nestjs/common' +import { + Controller, + Get, + Query, + Req, + Res, + VERSION_NEUTRAL, +} from '@nestjs/common' import { qsValidationPipe } from '../../utils/qs-validation-pipe' import { GetUserDto } from './dto/get-user.dto' @@ -17,9 +24,14 @@ export class UserController { @Get() async getUser( @Req() req: Request, + @Res({ passthrough: true }) res: Response, @Query(qsValidationPipe) query: GetUserDto, ): Promise { - return this.userService.getUser(req, query.refresh === 'true') + return this.userService.getUser({ + req, + res, + refresh: query.refresh === 'true', + }) } } diff --git a/apps/services/bff/src/app/modules/user/user.module.ts b/apps/services/bff/src/app/modules/user/user.module.ts index 6ad5d5368d65..d95c6b1cd7cc 100644 --- a/apps/services/bff/src/app/modules/user/user.module.ts +++ b/apps/services/bff/src/app/modules/user/user.module.ts @@ -1,13 +1,21 @@ import { Module } from '@nestjs/common' +import { CryptoService } from '../../services/crypto.service' +import { ErrorService } from '../../services/error.service' import { AuthModule } from '../auth/auth.module' +import { TokenRefreshService } from '../auth/token-refresh.service' import { IdsService } from '../ids/ids.service' import { UserController } from './user.controller' import { UserService } from './user.service' -import { CryptoService } from '../../services/crypto.service' @Module({ imports: [AuthModule], controllers: [UserController], - providers: [UserService, IdsService, CryptoService], + providers: [ + UserService, + IdsService, + CryptoService, + TokenRefreshService, + ErrorService, + ], }) export class UserModule {} diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index 2400ad7056e6..4babb4df7a4e 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -1,30 +1,25 @@ -import type { Logger } from '@island.is/logging' -import { LOGGER_PROVIDER } from '@island.is/logging' -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' -import { Request } from 'express' +import { Injectable, UnauthorizedException } from '@nestjs/common' +import type { Request, Response } from 'express' import { BffUser } from '@island.is/shared/types' import { SESSION_COOKIE_NAME } from '../../constants/cookies' -import { CryptoService } from '../../services/crypto.service' -import { hasTimestampExpiredInMS } from '../../utils/has-timestamp-expired-in-ms' -import { AuthService } from '../auth/auth.service' +import { ErrorService } from '../../services/error.service' import { CachedTokenResponse } from '../auth/auth.types' +import { TokenRefreshService } from '../auth/token-refresh.service' import { CacheService } from '../cache/cache.service' -import { IdsService } from '../ids/ids.service' @Injectable() export class UserService { constructor( - @Inject(LOGGER_PROVIDER) - private logger: Logger, - - private readonly cryptoService: CryptoService, private readonly cacheService: CacheService, - private readonly idsService: IdsService, - private readonly authService: AuthService, + private readonly tokenRefreshService: TokenRefreshService, + private readonly errorService: ErrorService, ) {} + /** + * Maps the cached token response to BFF user format + */ private mapToBffUser(value: CachedTokenResponse): BffUser { return { scopes: value.scopes, @@ -32,51 +27,56 @@ export class UserService { } } - public async getUser(req: Request, refresh = true): Promise { + /** + * Gets the current user data, refreshing the token if needed + */ + public async getUser({ + req, + res, + refresh = true, + }: { + req: Request + res: Response + refresh: boolean + }): Promise { const sid = req.cookies[SESSION_COOKIE_NAME] if (!sid) { throw new UnauthorizedException() } + const tokenResponseKey = this.cacheService.createSessionKeyType( + 'current', + sid, + ) + try { - const cachedTokenResponse = + let cachedTokenResponse: CachedTokenResponse | null = await this.cacheService.get( - this.cacheService.createSessionKeyType('current', sid), - // Do not throw error if the key is not found + tokenResponseKey, + // Don't throw if the key is not found false, ) - if (!cachedTokenResponse) { - throw new UnauthorizedException() + if (cachedTokenResponse && refresh) { + cachedTokenResponse = await this.tokenRefreshService.refreshToken({ + sid, + encryptedRefreshToken: cachedTokenResponse.encryptedRefreshToken, + }) } - const accessTokenHasExpired = hasTimestampExpiredInMS( - cachedTokenResponse.accessTokenExp, - ) - - if (accessTokenHasExpired && refresh) { - // Get new token data with refresh token - const tokenResponse = await this.idsService.refreshToken( - cachedTokenResponse.encryptedRefreshToken, - ) - - if (tokenResponse.type === 'error') { - throw tokenResponse.data - } - - // Update cache with new token data - const value: CachedTokenResponse = - await this.authService.updateTokenCache(tokenResponse.data) - - return this.mapToBffUser(value) + if (!cachedTokenResponse) { + throw new UnauthorizedException() } return this.mapToBffUser(cachedTokenResponse) } catch (error) { - this.logger.error('Get user error: ', error) - - throw new UnauthorizedException() + return this.errorService.handleAuthorizedError({ + error, + res, + tokenResponseKey, + operation: `${UserService.name}.getUser`, + }) } } } diff --git a/apps/services/bff/src/app/services/error.service.ts b/apps/services/bff/src/app/services/error.service.ts new file mode 100644 index 000000000000..8e4ad8efd551 --- /dev/null +++ b/apps/services/bff/src/app/services/error.service.ts @@ -0,0 +1,90 @@ +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import type { Response } from 'express' +import { SESSION_COOKIE_NAME } from '../constants/cookies' +import { CacheService } from '../modules/cache/cache.service' +import { getCookieOptions } from '../utils/get-cookie-options' + +/** + * Standard OAuth2 error codes returned by Identity Server + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + */ +export type OAuth2ErrorCode = + | 'invalid_grant' // Refresh token expired, invalid, or revoked + | 'invalid_client' // Client authentication failed + | 'invalid_request' // Missing or invalid parameters + | 'unauthorized_client' // Client not allowed to use grant type + | 'unsupported_grant_type' // Grant type not supported + | 'invalid_scope' // Requested scope is invalid/unknown + +export const OAUTH2_ERROR_CODES: OAuth2ErrorCode[] = [ + 'invalid_grant', + 'invalid_client', + 'invalid_request', + 'unauthorized_client', + 'unsupported_grant_type', + 'invalid_scope', +] + +@Injectable() +export class ErrorService { + constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, + + private readonly cacheService: CacheService, + ) {} + + /** + * Validates if the given string is a known OAuth2 error code + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + */ + private isKnownOAuth2ErrorCode(code: string): code is OAuth2ErrorCode { + return OAUTH2_ERROR_CODES.includes(code as OAuth2ErrorCode) + } + + /** + * Handles authorization errors by cleaning up user session and logging the error + * + * @param params Object containing error handling parameters + * @param params.res - Express Response object for clearing cookies + * @param params.sid - Session ID of the user + * @param params.operation - Name of the operation that failed (e.g., 'get user', 'refresh token') + * @param params.error - The error that was caught + * @param params.tokenResponseKey - Redis key for the token that needs to be cleaned up + * + * @throws UnauthorizedException after cleanup is complete + */ + async handleAuthorizedError({ + res, + operation, + error, + tokenResponseKey, + }: { + res: Response + operation: string + error: unknown + tokenResponseKey: string + }): Promise { + const errorCode = (error as { body?: { error?: string } })?.body?.error + + // If the error is an OAuth2 error + // 1. Delete the cached token response + // 2. Clear the session cookie + // 3. Throw an UnauthorizedException + if (errorCode && this.isKnownOAuth2ErrorCode(errorCode)) { + this.logger.warn( + `${operation} failed with OAuth2 error: ${errorCode}`, + error, + ) + + res.clearCookie(SESSION_COOKIE_NAME, getCookieOptions()) + await this.cacheService.delete(tokenResponseKey) + + throw new UnauthorizedException() + } + + throw error + } +} diff --git a/apps/services/bff/src/app/utils/get-cookie-options.ts b/apps/services/bff/src/app/utils/get-cookie-options.ts new file mode 100644 index 000000000000..ab59c25646ca --- /dev/null +++ b/apps/services/bff/src/app/utils/get-cookie-options.ts @@ -0,0 +1,13 @@ +import { CookieOptions } from 'express' +import { environment } from '../../environment' + +export const getCookieOptions = (): CookieOptions => { + return { + httpOnly: true, + secure: true, + // The lax setting allows cookies to be sent on top-level navigations (such as redirects), + // while still providing some protection against CSRF attacks. + sameSite: 'lax', + path: environment.keyPath, + } +}