Skip to content
93 changes: 90 additions & 3 deletions backend/src/common/auth/keycloak-auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'
import { HttpService } from '@nestjs/axios'
import { ConfigService } from '@nestjs/config'
import { of } from 'rxjs'
import { Test, TestingModule } from '@nestjs/testing'
import { of, throwError } from 'rxjs'
import { KeycloakAuthService } from './keycloak-auth.service'

describe('KeycloakAuthService', () => {
Expand Down Expand Up @@ -35,7 +35,7 @@ describe('KeycloakAuthService', () => {
})

it('should request token from Keycloak with client_credentials', async () => {
httpService.post.mockReturnValue(of({ data: { access_token: 'test-token' } }))
httpService.post.mockReturnValue(of({ data: { access_token: 'test-token', expires_in: 300 } }))

const token = await service.getBearerToken()

Expand All @@ -48,4 +48,91 @@ describe('KeycloakAuthService', () => {
}),
)
})

it('should reuse cached token on subsequent calls', async () => {
httpService.post.mockReturnValue(of({ data: { access_token: 'test-token', expires_in: 300 } }))

const token1 = await service.getBearerToken()
const token2 = await service.getBearerToken()

expect(token1).toBe('test-token')
expect(token2).toBe('test-token')
// Should only make one HTTP call since token is cached
expect(httpService.post).toHaveBeenCalledTimes(1)
})

it('should refresh token when access token is expired', async () => {
// First call returns token with refresh_token
httpService.post.mockReturnValueOnce(
of({
data: {
access_token: 'initial-token',
refresh_token: 'refresh-token',
expires_in: 1, // Very short expiry
},
}),
)

const token1 = await service.getBearerToken()
expect(token1).toBe('initial-token')

// Wait for token to expire (considering the 60s buffer, it should already be considered expired)
// Second call should use refresh_token
httpService.post.mockReturnValueOnce(
of({
data: {
access_token: 'refreshed-token',
refresh_token: 'new-refresh-token',
expires_in: 300,
},
}),
)

const token2 = await service.getBearerToken()
expect(token2).toBe('refreshed-token')
expect(httpService.post).toHaveBeenCalledTimes(2)
})

it('should fall back to client_credentials when refresh fails', async () => {
// First call returns token with refresh_token
httpService.post.mockReturnValueOnce(
of({
data: {
access_token: 'initial-token',
refresh_token: 'refresh-token',
expires_in: 1,
},
}),
)

await service.getBearerToken()

// Refresh fails
httpService.post.mockReturnValueOnce(throwError(() => new Error('Refresh failed')))
// Fallback to client_credentials succeeds
httpService.post.mockReturnValueOnce(
of({
data: {
access_token: 'new-token',
expires_in: 300,
},
}),
)

const token = await service.getBearerToken()
expect(token).toBe('new-token')
})

it('should clear token cache when clearTokenCache is called', async () => {
httpService.post.mockReturnValue(of({ data: { access_token: 'test-token', expires_in: 300 } }))

await service.getBearerToken()
expect(httpService.post).toHaveBeenCalledTimes(1)

service.clearTokenCache()

await service.getBearerToken()
// Should make a new HTTP call after cache is cleared
expect(httpService.post).toHaveBeenCalledTimes(2)
})
})
109 changes: 101 additions & 8 deletions backend/src/common/auth/keycloak-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,53 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { firstValueFrom } from 'rxjs'

interface TokenCache {
accessToken: string
refreshToken: string | null
expiresAt: number // Unix timestamp in milliseconds
}

@Injectable()
export class KeycloakAuthService {
private readonly logger = new Logger(KeycloakAuthService.name)
private tokenCache: TokenCache | null = null
// Refresh token 60 seconds before expiry to avoid race conditions
private readonly TOKEN_EXPIRY_BUFFER_MS = 60 * 1000

constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}

async getBearerToken(): Promise<string> {
// Check if we have a valid cached token
if (this.tokenCache && this.isTokenValid()) {
this.logger.debug('Using cached access token')
return this.tokenCache.accessToken
}

// Try to refresh if we have a refresh token
if (this.tokenCache?.refreshToken) {
try {
this.logger.log('Access token expired, attempting to refresh...')
return await this.refreshAccessToken()
} catch (error) {
this.logger.warn('Failed to refresh token, falling back to client_credentials:', error)
// Fall through to get a new token
}
}

// Get a new token using client_credentials
return await this.fetchNewToken()
}

private isTokenValid(): boolean {
if (!this.tokenCache) return false
const now = Date.now()
return this.tokenCache.expiresAt - this.TOKEN_EXPIRY_BUFFER_MS > now
}

private async fetchNewToken(): Promise<string> {
const keycloakTokenUrl = this.configService.get<string>('admin.keycloakTokenUrl')!
const keycloakClientId = this.configService.get<string>('admin.keycloakClientId')!
const keycloakClientSecret = this.configService.get<string>('admin.keycloakClientSecret')!
Expand All @@ -22,24 +59,80 @@ export class KeycloakAuthService {
params.append('client_id', keycloakClientId)
params.append('client_secret', keycloakClientSecret)

this.logger.log('Requesting Keycloak token from:', keycloakTokenUrl)
try {
this.logger.log('Requesting new Keycloak token from:', keycloakTokenUrl)
const response = await firstValueFrom(
this.httpService.post(keycloakTokenUrl, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}),
)

this.cacheTokenResponse(response.data)
this.logger.log('New Keycloak token obtained successfully')

return response.data.access_token
} catch (error) {
this.logger.error('Failed to obtain Keycloak bearer token:', error)
throw new HttpException(
'Failed to authenticate with ICM service',
HttpStatus.INTERNAL_SERVER_ERROR,
)
}
}

private async refreshAccessToken(): Promise<string> {
const keycloakTokenUrl = this.configService.get<string>('admin.keycloakTokenUrl')!
const keycloakClientId = this.configService.get<string>('admin.keycloakClientId')!
const keycloakClientSecret = this.configService.get<string>('admin.keycloakClientSecret')!

const params = new URLSearchParams()
params.append('grant_type', 'refresh_token')
params.append('refresh_token', this.tokenCache!.refreshToken!)
params.append('client_id', keycloakClientId)
params.append('client_secret', keycloakClientSecret)

this.logger.log('Refreshing Keycloak token...')
const response = await firstValueFrom(
this.httpService.post(keycloakTokenUrl, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}),
)
// TODO: this.logger.log('ICM token token response received ', response.data.access_token)
this.logger.log('ICM token token response received ')

this.cacheTokenResponse(response.data)
this.logger.log('Keycloak token refreshed successfully')

return response.data.access_token
}
catch(error) {
console.error('Failed to obtain ICM bearer token:', error)
throw new HttpException(
'Failed to authenticate with ICM service',
HttpStatus.INTERNAL_SERVER_ERROR,

private cacheTokenResponse(data: {
access_token: string
refresh_token?: string
expires_in: number
}): void {
const now = Date.now()
// expires_in is in seconds, convert to milliseconds
const expiresAt = now + data.expires_in * 1000

this.tokenCache = {
accessToken: data.access_token,
refreshToken: data.refresh_token || this.tokenCache?.refreshToken || null,
expiresAt,
}

this.logger.debug(
`Token cached, expires at ${new Date(expiresAt).toISOString()} (in ${data.expires_in} seconds)`,
)
}

/**
* Force clear the token cache. Useful for testing or when token is revoked.
*/
clearTokenCache(): void {
this.tokenCache = null
this.logger.log('Token cache cleared')
}
}
Loading