Skip to content
Closed
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
10 changes: 9 additions & 1 deletion apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"test:debug": "vitest --inspect"
},
"dependencies": {
"@casl/ability": "^6.8.0",
"@casl/prisma": "^1.6.1",
"@cpn-console/argocd-plugin": "workspace:^",
"@cpn-console/gitlab-plugin": "workspace:^",
"@cpn-console/harbor-plugin": "workspace:^",
Expand All @@ -45,9 +47,11 @@
"@gitbeaker/core": "^40.6.0",
"@gitbeaker/rest": "^40.6.0",
"@kubernetes-models/argo-cd": "^2.7.2",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.16",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.16",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.1.16",
"@nestjs/terminus": "^11.1.1",
"@opentelemetry/api": "^1.9.0",
Expand All @@ -64,21 +68,25 @@
"@ts-rest/fastify": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"axios": "^1.13.6",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"date-fns": "^4.1.0",
"dotenv": "^16.6.1",
"fastify": "^4.29.1",
"fastify-keycloak-adapter": "2.3.2",
"json-2-csv": "^5.5.10",
"keycloak-connect": "^26.1.1",
"mustache": "^4.2.0",
"nest-keycloak-connect": "2.0.0-alpha.2",
"nestjs-pino": "^4.6.0",
"pino-http": "^11.0.0",
"prisma": "^6.19.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"undici": "^7.24.0",
"vitest-mock-extended": "^2.0.2"
"vitest-mock-extended": "^2.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@cpn-console/eslint-config": "workspace:^",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Controller, Get, Inject } from '@nestjs/common'
import { HealthCheck, HealthCheckService } from '@nestjs/terminus'
import { DatabaseHealthService } from '../database/database-health.service'

@Controller('/api/v1/health')
export class HealthController {
constructor(
@Inject(HealthCheckService) private readonly health: HealthCheckService,
@Inject(DatabaseHealthService) private readonly database: DatabaseHealthService,
) {}

@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.database.check('database'),
])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { TestingModule } from '@nestjs/testing'
import type { Mock } from 'vitest'
import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { JwtService } from '@nestjs/jwt'
import { Test } from '@nestjs/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ConfigurationService } from '../configuration/configuration.service'
import { AuthService } from './auth.service'

vi.mock('node:crypto', async (importOriginal) => {
const mod = await importOriginal<typeof import('node:crypto')>()
return {
...mod,
createPublicKey: vi.fn().mockReturnValue({
export: vi.fn().mockReturnValue('mocked-pem'),
}),
}
})

describe('authService', () => {
let service: AuthService
let cacheManager: { get: Mock, set: Mock }
let jwtService: { decode: Mock, verifyAsync: Mock }
let configService: { keycloakClientId: string }

const mockJwks = {
keys: [
{
kty: 'RSA',
kid: 'test-kid',
use: 'sig',
n: 'test-n-base64', // normally a valid base64url string, but z.string() just checks if it's a string
e: 'AQAB',
},
],
}

const mockJwt = {
header: { kid: 'test-kid' },
payload: { iss: 'http://test-issuer' },
}

const mockPayload = {
sub: 'user-id',
iss: 'http://test-issuer',
aud: 'test-client',
}

beforeEach(async () => {
cacheManager = { get: vi.fn(), set: vi.fn() }
jwtService = { decode: vi.fn(), verifyAsync: vi.fn() }
configService = { keycloakClientId: 'test-client' }

const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: CACHE_MANAGER, useValue: cacheManager },
{ provide: JwtService, useValue: jwtService },
{ provide: ConfigurationService, useValue: configService },
],
}).compile()

service = module.get<AuthService>(AuthService)

// Mock global fetch
globalThis.fetch = vi.fn()
})

afterEach(() => {
vi.resetAllMocks()
})

it('should be defined', () => {
expect(service).toBeDefined()
})

describe('verifyToken', () => {
it('should return null if token decode fails', async () => {
jwtService.decode.mockReturnValue(null)
const result = await service.verifyToken('invalid-token')
expect(result).toBeNull()
})

it('should return null if kid is missing in jwks', async () => {
jwtService.decode.mockReturnValue(mockJwt)
cacheManager.get.mockResolvedValue({ keys: [] })

const result = await service.verifyToken('valid-token')
expect(result).toBeNull()
})

it('should fetch JWKS and cache it if not in cache', async () => {
jwtService.decode.mockReturnValue(mockJwt)
cacheManager.get.mockResolvedValue(null)
;(globalThis.fetch as Mock).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockJwks),
headers: { get: vi.fn().mockReturnValue('max-age=3600') },
})
jwtService.verifyAsync.mockResolvedValue(mockPayload)

const result = await service.verifyToken('valid-token')

expect(globalThis.fetch).toHaveBeenCalledWith('http://test-issuer/protocol/openid-connect/certs', expect.any(Object))
expect(cacheManager.set).toHaveBeenCalledWith('jwks:http://test-issuer', mockJwks, 3600000)
expect(jwtService.verifyAsync).toHaveBeenCalledWith('valid-token', {
publicKey: 'mocked-pem',
issuer: 'http://test-issuer',
audience: 'test-client',
algorithms: ['RS256'],
})
expect(result).toEqual(mockPayload)
})

it('should return null if verifyAsync throws', async () => {
jwtService.decode.mockReturnValue(mockJwt)
cacheManager.get.mockResolvedValue(mockJwks)
jwtService.verifyAsync.mockRejectedValue(new Error('verify failed'))

const result = await service.verifyToken('valid-token')
expect(result).toBeNull()
})

it('should return null if verified payload fails zod schema', async () => {
jwtService.decode.mockReturnValue(mockJwt)
cacheManager.get.mockResolvedValue(mockJwks)
jwtService.verifyAsync.mockResolvedValue({ invalid: 'payload', sub: 123 }) // sub should be string

const result = await service.verifyToken('valid-token')
expect(result).toBeNull()
})
})
})
141 changes: 141 additions & 0 deletions apps/server-nestjs/src/cpin-module/infrastructure/iam/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Cache } from '@nestjs/cache-manager'
import { createPublicKey } from 'node:crypto'
import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { Inject, Injectable } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { z } from 'zod'
import { ConfigurationService } from '../configuration/configuration.service'

export const API_USER = Symbol('API_USER')

const jwkSchema = z.object({
kid: z.string(),
kty: z.string(),
use: z.string().optional(),
alg: z.string().optional(),
n: z.string().optional(),
e: z.string().optional(),
x5c: z.array(z.string()).optional(),
}).passthrough()

const rsaJwkSchema = jwkSchema.extend({
kty: z.literal('RSA'),
n: z.string(),
e: z.string(),
})

const jwksSchema = z.object({
keys: z.array(jwkSchema),
})

export type Jwks = z.infer<typeof jwksSchema>

const verifiedJwtPayloadSchema = z.record(z.unknown()).and(z.object({
sub: z.string().optional(),
exp: z.number().optional(),
iat: z.number().optional(),
jti: z.string().optional(),
iss: z.string().optional(),
aud: z.union([z.string(), z.array(z.string())]).optional(),
}))

export type VerifiedJwtPayload = z.infer<typeof verifiedJwtPayloadSchema>

const maxAgeRegex = /max-age=(\d+)/

const jwtSchema = z.object({
header: z.object({ kid: z.string() }).passthrough(),
payload: z.object({ iss: z.string() }).passthrough(),
}).passthrough()

export type Jwt = z.infer<typeof jwtSchema>

@Injectable()
export class AuthService {
constructor(
@Inject(ConfigurationService) private readonly config: ConfigurationService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(JwtService) private readonly jwt: JwtService,
) {}

private async getJwksWithCache(issuer: string) {
return await this.getJwksFromCache(issuer) ?? await this.getJwks(issuer)
}

private async getJwks(issuer: string): Promise<Jwks> {
const response = await fetch(`${issuer}/protocol/openid-connect/certs`, {
headers: { accept: 'application/json' },
})

if (!response.ok)
throw new Error(`JWKS fetch failed: ${response.status}`)

const jwks = jwksSchema.parse(await response.json())
const cacheControl = response.headers.get('cache-control') ?? ''
const match = cacheControl.match(maxAgeRegex)
const maxAgeMs = match?.[1] ? Number.parseInt(match[1], 10) * 1000 : 60_000
await this.setJwksInCache(issuer, jwks, maxAgeMs)
return jwks
}

private async getJwksFromCache(issuer: string): Promise<Jwks | undefined> {
const cached = await this.cache.get<unknown>(generateJwksCacheKey(issuer))
const parsed = jwksSchema.safeParse(cached)
return parsed.success ? parsed.data : undefined
}

private async setJwksInCache(issuer: string, jwks: Jwks, maxAgeMs: number) {
await this.cache.set(generateJwksCacheKey(issuer), jwks, maxAgeMs)
}

private async getPublicKey(jwt: Jwt) {
const { keys } = await this.getJwksWithCache(jwt.payload.iss)
const candidateKey = keys.find(k => k.kid === jwt.header.kid)
const parsed = rsaJwkSchema.safeParse(candidateKey)
if (!parsed.success) {
console.log('rsaJwkSchema parsing failed:', parsed.error)
return null
}
return createPublicKey({ key: parsed.data, format: 'jwk' })
}

private decode(token: string): Jwt | null {
const decoded = this.jwt.decode<unknown>(token, { complete: true })
const parsed = jwtSchema.safeParse(decoded)
return parsed.success ? parsed.data : null
}

async verifyToken(token: string): Promise<VerifiedJwtPayload | null> {
try {
const jwt = this.decode(token)
if (!jwt) {
console.log('decode returned null')
return null
}

const publicKey = await this.getPublicKey(jwt)
if (!publicKey) {
console.log('getPublicKey returned null')
return null
}

console.log('calling verifyAsync')
const payload = await this.jwt.verifyAsync<Record<string, unknown>>(token, {
publicKey: publicKey.export({ format: 'pem', type: 'spki' }).toString(),
issuer: jwt.payload.iss,
audience: this.config.keycloakClientId,
algorithms: ['RS256'],
})

const parsed = verifiedJwtPayloadSchema.safeParse(payload)
return parsed.success ? parsed.data : null
} catch (e) {
console.log('verifyToken failed:', e)
return null
}
}
}

function generateJwksCacheKey(issuer: string) {
return `jwks:${issuer}`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common'
import type { Request } from 'express'
import type { AppAbility, AppAction, AppSubject } from '../middleware/ability.middleware'

export class AbilityGuard implements CanActivate {
constructor(
private readonly action: AppAction,
private readonly subject: AppSubject,
) {}

canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request & { ability?: AppAbility }>()
return req.ability?.can(this.action, this.subject) ?? false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common'
import type { Request } from 'express'
import type { VerifiedJwtPayload } from '../auth.service'
import { tokenHeaderName } from '@cpn-console/shared'
import { Inject, Injectable } from '@nestjs/common'
import { AuthService } from '../auth.service'

export interface RequestUser {
user?: VerifiedJwtPayload | null
}

@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Inject(AuthService) private readonly authService: AuthService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
if (context.getType() !== 'http')
return true

const request = context.switchToHttp().getRequest<Request & RequestUser>()
if (request.user)
return true

const token = request.headers[tokenHeaderName]
if (typeof token !== 'string')
return false

const payload = await this.authService.verifyToken(token)
if (!payload)
return false
request.user = payload

return true
}
}
Loading
Loading