Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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')
}
}
38 changes: 38 additions & 0 deletions backend/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,41 @@ export function getAgeCutoffDate(referenceDate: Date = pacificToday()): Date {
export function isEligibleAge(dateOfBirth: Date, referenceDate: Date = pacificToday()): boolean {
return dateOfBirth >= getAgeCutoffDate(referenceDate)
}

export interface S3ConnectionParams {
endPoint: string
port: number | undefined
useSSL: boolean
accessKey: string
secretKey: string
}

// Parses s3URI manually because `new URL()` breaks when credentials contain `/` or `@`
export function parseS3Uri(uri: string): S3ConnectionParams {
const schemeMatch = uri.match(/^(https?):\/\/(.+)$/)
if (!schemeMatch) throw new Error('Invalid s3URI: missing http(s):// scheme')

const [, scheme, rest] = schemeMatch

const lastAt = rest.lastIndexOf('@')
if (lastAt === -1) throw new Error('Invalid s3URI: expected user:pass@host')

const credentials = rest.substring(0, lastAt)
const hostPart = rest.substring(lastAt + 1).split('/')[0]

const firstColon = credentials.indexOf(':')
if (firstColon === -1) throw new Error('Invalid s3URI: expected user:pass@host')

const accessKey = credentials.substring(0, firstColon)
const secretKey = credentials.substring(firstColon + 1)

const [host, portStr] = hostPart.split(':')

return {
endPoint: host,
port: portStr ? parseInt(portStr, 10) : undefined,
useSSL: scheme === 'https',
accessKey,
secretKey,
}
}
2 changes: 2 additions & 0 deletions backend/src/config/cra.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const craConfig = registerAs('cra', () => {

return {
enabled: process.env.CRA_INTEGRATION_ENABLED === 'true',
transferMode: process.env.CRA_TRANSFER_MODE || 's3',
s3Prefix: process.env.CRA_S3_PREFIX || '',
environmentCode: isProduction ? 'PCSAIN' : 'ACSAIN',
fileTypeCode: isProduction ? 'PAPL' : 'AAPL',
fileNamePrefix: isProduction ? 'HT' : 'II',
Expand Down
2 changes: 1 addition & 1 deletion backend/src/cra/cra.constant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const CRA_DATA_HANDLING_CONSTANT = {
DESTINATION_ID: 'cra-ftp',
DESTINATION_ID: 'cra',
RESPONSE_FILE_TYPE: {
RSP: 'RSP',
WKL: 'WKL',
Expand Down
6 changes: 5 additions & 1 deletion backend/src/cra/cra.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing'
import { JobType } from 'src/jobs/enums/job-type.enum'
import { JobRegistry } from 'src/jobs/job-registry.service'
import { JobsModule } from 'src/jobs/jobs.module'
import { SyncIcmHandler } from 'src/sync/handlers/sync-icm.handler'
import { CraModule } from './cra.module'
import { PollCraResponseHandler } from './handlers/poll-cra-response.handler'
import { SendCraFileHandler } from './handlers/send-cra-file.handler'
Expand All @@ -29,17 +30,20 @@ describe('CraModule', () => {
expect(craModule).toBeDefined()
})

it('should register both CRA handlers', () => {
it('should register all CRA handlers including SYNC_ICM', () => {
expect(registry.hasHandler(JobType.SEND_CRA_FILE)).toBe(true)
expect(registry.hasHandler(JobType.POLL_CRA_RESPONSE)).toBe(true)
expect(registry.hasHandler(JobType.SYNC_ICM)).toBe(true)
})

it('should register handlers with correct types', () => {
const sendHandler = registry.getHandler(JobType.SEND_CRA_FILE)
const pollHandler = registry.getHandler(JobType.POLL_CRA_RESPONSE)
const syncHandler = registry.getHandler(JobType.SYNC_ICM)

expect(sendHandler).toBeInstanceOf(SendCraFileHandler)
expect(pollHandler).toBeInstanceOf(PollCraResponseHandler)
expect(syncHandler).toBeInstanceOf(SyncIcmHandler)
})

it('should export all handler providers', () => {
Expand Down
Loading
Loading