Skip to content
Open
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
146 changes: 146 additions & 0 deletions src/utils/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect, beforeAll } from 'vitest'
import { generateKeyPairSync } from 'node:crypto'
import {
signJwt,
verifyJwt,
decodeJwt,
JwtExpiredError,
JwtInvalidError,
type HS256Config,
type RS256Config,
} from './jwt.js'

const HS256: HS256Config = {
algorithm: 'HS256',
secret: 'test-secret-at-least-32-bytes-long!!',
}

let RS256: RS256Config

beforeAll(() => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
RS256 = { algorithm: 'RS256', privateKey, publicKey }
})

describe('signJwt + verifyJwt — HS256', () => {
it('should round-trip a custom payload', async () => {
const token = await signJwt({ userId: '42', role: 'admin' }, HS256)
const payload = await verifyJwt(token, HS256)
expect(payload['userId']).toBe('42')
expect(payload['role']).toBe('admin')
})

it('should embed iss, aud, sub, jti when provided', async () => {
const token = await signJwt({}, HS256, {
issuer: 'opendev',
audience: 'api',
subject: 'user-1',
jwtId: 'abc-123',
})
const payload = await verifyJwt(token, HS256, {
issuer: 'opendev',
audience: 'api',
})
expect(payload.iss).toBe('opendev')
expect(payload.aud).toContain('api')
expect(payload.sub).toBe('user-1')
expect(payload.jti).toBe('abc-123')
})

it('should accept an audience array and verify against any member', async () => {
const token = await signJwt({}, HS256, { audience: ['api', 'dashboard'] })
const payload = await verifyJwt(token, HS256, { audience: 'api' })
expect(payload.aud).toContain('dashboard')
})

it('should set iat and exp claims automatically', async () => {
const before = Math.floor(Date.now() / 1000)
const token = await signJwt({}, HS256, { expiresIn: 60 })
const payload = await verifyJwt(token, HS256)
const after = Math.floor(Date.now() / 1000)

expect(payload.iat).toBeGreaterThanOrEqual(before)
expect(payload.iat).toBeLessThanOrEqual(after)
expect(payload.exp).toBeGreaterThanOrEqual(before + 60)
})

it('should throw JwtExpiredError when the token lifetime is in the past', async () => {
// expiresIn: -1 sets exp to 1 second ago
const token = await signJwt({}, HS256, { expiresIn: -1 })
await expect(verifyJwt(token, HS256)).rejects.toBeInstanceOf(JwtExpiredError)
})

it('should throw JwtInvalidError when the secret is wrong', async () => {
const token = await signJwt({}, HS256)
const wrong: HS256Config = { algorithm: 'HS256', secret: 'wrong-secret' }
await expect(verifyJwt(token, wrong)).rejects.toBeInstanceOf(JwtInvalidError)
})

it('should throw JwtInvalidError when the expected issuer does not match', async () => {
const token = await signJwt({}, HS256, { issuer: 'opendev' })
await expect(
verifyJwt(token, HS256, { issuer: 'other' })
).rejects.toBeInstanceOf(JwtInvalidError)
})

it('should throw JwtInvalidError when the expected audience does not match', async () => {
const token = await signJwt({}, HS256, { audience: 'api' })
await expect(
verifyJwt(token, HS256, { audience: 'dashboard' })
).rejects.toBeInstanceOf(JwtInvalidError)
})

it('should throw JwtInvalidError for a structurally invalid token string', async () => {
await expect(verifyJwt('not-a-jwt', HS256)).rejects.toBeInstanceOf(JwtInvalidError)
})
})

describe('signJwt + verifyJwt — RS256', () => {
it('should round-trip a payload using an RSA key pair', async () => {
const token = await signJwt({ userId: '99' }, RS256)
const payload = await verifyJwt(token, RS256)
expect(payload['userId']).toBe('99')
})

it('should throw JwtInvalidError when verified with a different public key', async () => {
const token = await signJwt({}, RS256)
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
const other: RS256Config = { algorithm: 'RS256', privateKey, publicKey }
await expect(verifyJwt(token, other)).rejects.toBeInstanceOf(JwtInvalidError)
})

it('should embed issuer and audience in RS256 tokens', async () => {
const token = await signJwt({}, RS256, { issuer: 'opendev', audience: 'svc-api' })
const payload = await verifyJwt(token, RS256, {
issuer: 'opendev',
audience: 'svc-api',
})
expect(payload.iss).toBe('opendev')
expect(payload.aud).toContain('svc-api')
})
})

describe('decodeJwt', () => {
it('should decode claims without verifying the signature', async () => {
const token = await signJwt({ userId: '7' }, HS256)
const payload = decodeJwt(token)
expect(payload['userId']).toBe('7')
})

it('should decode an expired token without throwing', async () => {
const token = await signJwt({}, HS256, { expiresIn: -1 })
expect(() => decodeJwt(token)).not.toThrow()
})

it('should throw JwtInvalidError for a malformed token string', () => {
expect(() => decodeJwt('not.a.valid.jwt')).toThrow(JwtInvalidError)
})
})
158 changes: 158 additions & 0 deletions src/utils/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
SignJWT,
jwtVerify,
decodeJwt as joseDecodeJwt,
importPKCS8,
importSPKI,
errors as joseErrors,
type JWTPayload,
type KeyLike,
} from 'jose'

export type Algorithm = 'HS256' | 'RS256'

export interface HS256Config {
algorithm: 'HS256'
secret: string | Uint8Array
}

export interface RS256Config {
algorithm: 'RS256'
/** PEM-encoded PKCS8 private key — used only for signing */
privateKey: string
/** PEM-encoded SPKI public key — used only for verification */
publicKey: string
}

export type AlgorithmConfig = HS256Config | RS256Config

export interface SignOptions {
issuer?: string
audience?: string | string[]
subject?: string
/** Lifetime in seconds. Defaults to 3600 (1 hour). */
expiresIn?: number
jwtId?: string
/** Seconds from now before which the token must not be accepted. */
notBefore?: number
}

export interface VerifyOptions {
issuer?: string
audience?: string | string[]
}

export class JwtExpiredError extends Error {
constructor(message = 'JWT has expired') {
super(message)
this.name = 'JwtExpiredError'
}
}

export class JwtInvalidError extends Error {
constructor(message: string) {
super(message)
this.name = 'JwtInvalidError'
}
}

async function resolveSigningKey(
config: AlgorithmConfig
): Promise<KeyLike | Uint8Array> {
if (config.algorithm === 'HS256') {
return typeof config.secret === 'string'
? new TextEncoder().encode(config.secret)
: config.secret
}
return importPKCS8(config.privateKey, 'RS256')
}

async function resolveVerificationKey(
config: AlgorithmConfig
): Promise<KeyLike | Uint8Array> {
if (config.algorithm === 'HS256') {
return typeof config.secret === 'string'
? new TextEncoder().encode(config.secret)
: config.secret
}
return importSPKI(config.publicKey, 'RS256')
}

/**
* Signs a JWT with the given payload and config.
* Includes iat automatically; exp defaults to 1 hour.
*/
export async function signJwt(
payload: Record<string, unknown>,
config: AlgorithmConfig,
options: SignOptions = {}
): Promise<string> {
const { issuer, audience, subject, expiresIn = 3600, jwtId, notBefore } = options

const key = await resolveSigningKey(config)

let builder = new SignJWT(payload)
.setProtectedHeader({ alg: config.algorithm })
.setIssuedAt()
.setExpirationTime(`${expiresIn}s`)

if (issuer !== undefined) builder = builder.setIssuer(issuer)
if (audience !== undefined)
builder = builder.setAudience(Array.isArray(audience) ? audience : [audience])
if (subject !== undefined) builder = builder.setSubject(subject)
if (jwtId !== undefined) builder = builder.setJti(jwtId)
if (notBefore !== undefined) builder = builder.setNotBefore(`${notBefore}s`)

return builder.sign(key)
}

/**
* Verifies a JWT and returns its payload.
* Throws JwtExpiredError if the token has expired.
* Throws JwtInvalidError for any other verification failure.
*/
export async function verifyJwt(
token: string,
config: AlgorithmConfig,
options: VerifyOptions = {}
): Promise<JWTPayload> {
const key = await resolveVerificationKey(config)

const joseOptions: Parameters<typeof jwtVerify>[2] = {
algorithms: [config.algorithm],
}

if (options.issuer !== undefined) joseOptions.issuer = options.issuer
if (options.audience !== undefined) {
joseOptions.audience = Array.isArray(options.audience)
? options.audience
: [options.audience]
}

try {
const { payload } = await jwtVerify(token, key, joseOptions)
return payload
} catch (err) {
if (err instanceof joseErrors.JWTExpired) {
throw new JwtExpiredError()
}
throw new JwtInvalidError(
err instanceof Error ? err.message : 'Token verification failed'
)
}
}

/**
* Decodes a JWT without verifying its signature.
* Useful for extracting claims from a trusted internal token or for debugging.
* Throws JwtInvalidError if the token is malformed.
*/
export function decodeJwt(token: string): JWTPayload {
try {
return joseDecodeJwt(token)
} catch (err) {
throw new JwtInvalidError(
err instanceof Error ? err.message : 'Token decoding failed'
)
}
}
Loading