Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417/** * OIDC Token Service * * Handles creation and verification of OIDC JWTs (ID tokens, access tokens) */
import crypto from 'crypto';import type { KeyManager } from './keys.js';import type { IDTokenClaims, AccessTokenClaims, TokenResponse } from '../../types/index.js';
export class TokenService { private isRevoked?: (jti: string) => boolean;
constructor( private keyManager: KeyManager, private issuer: string ) {}
/** * Set a revocation checker function (called during access token verification) */ setRevocationChecker(checker: (jti: string) => boolean): void { this.isRevoked = checker; }
/** * Create an ID Token */ createIdToken(claims: { sub: string; aud: string; nonce?: string; did: string; handle: string; accessToken?: string; expiresIn?: number; }): string { const signingKey = this.keyManager.getSigningKey(); if (!signingKey) { throw new Error('No signing key available'); }
const now = Math.floor(Date.now() / 1000); const expiresIn = claims.expiresIn || 3600;
const payload: IDTokenClaims = { iss: this.issuer, sub: claims.sub, aud: claims.aud, exp: now + expiresIn, iat: now, nonce: claims.nonce, handle: claims.handle, did: claims.did, };
// Add at_hash if access token is provided if (claims.accessToken) { payload.at_hash = this.computeAtHash(claims.accessToken, signingKey.algorithm); }
return this.signJwt(payload as unknown as Record<string, unknown>, signingKey); }
/** * Create an Access Token */ createAccessToken(claims: { sub: string; clientId: string; scope: string; expiresIn?: number; }): string { const signingKey = this.keyManager.getSigningKey(); if (!signingKey) { throw new Error('No signing key available'); }
const now = Math.floor(Date.now() / 1000); const expiresIn = claims.expiresIn || 3600;
const payload: AccessTokenClaims = { iss: this.issuer, sub: claims.sub, aud: this.issuer, exp: now + expiresIn, iat: now, jti: crypto.randomUUID(), client_id: claims.clientId, scope: claims.scope, };
return this.signJwt(payload as unknown as Record<string, unknown>, signingKey); }
/** * Create a token response for the token endpoint */ createTokenResponse(params: { sub: string; clientId: string; scope: string; did: string; handle: string; nonce?: string; accessTokenTtl?: number; idTokenTtl?: number; includeRefreshToken?: boolean; refreshToken?: string; }): TokenResponse { const accessTokenTtl = params.accessTokenTtl || 3600; const idTokenTtl = params.idTokenTtl || 3600;
const accessToken = this.createAccessToken({ sub: params.sub, clientId: params.clientId, scope: params.scope, expiresIn: accessTokenTtl, });
const response: TokenResponse = { access_token: accessToken, token_type: 'Bearer', expires_in: accessTokenTtl, scope: params.scope, };
// Include ID token if openid scope is requested if (params.scope.includes('openid')) { response.id_token = this.createIdToken({ sub: params.sub, aud: params.clientId, nonce: params.nonce, did: params.did, handle: params.handle, accessToken, expiresIn: idTokenTtl, }); }
// Include refresh token if requested if (params.includeRefreshToken && params.refreshToken) { response.refresh_token = params.refreshToken; }
return response; }
/** * Verify an access token */ verifyAccessToken(token: string): AccessTokenClaims | null { try { const payload = this.verifyJwt(token); if (!payload) return null;
// Check required claims if (!payload.iss || !payload.sub || !payload.exp || !payload.client_id) { return null; }
// Check issuer if (payload.iss !== this.issuer) { return null; }
// Check expiration const now = Math.floor(Date.now() / 1000); if (typeof payload.exp !== 'number' || payload.exp < now) { return null; }
// Check revocation blacklist if (this.isRevoked && typeof payload.jti === 'string' && this.isRevoked(payload.jti)) { return null; }
return payload as unknown as AccessTokenClaims; } catch { return null; } }
/** * Verify an ID token */ verifyIdToken(token: string, expectedAudience?: string, expectedNonce?: string): IDTokenClaims | null { try { const payload = this.verifyJwt(token); if (!payload) return null;
// Check required claims if (!payload.iss || !payload.sub || !payload.aud || !payload.exp) { return null; }
// Check issuer if (payload.iss !== this.issuer) { return null; }
// Check audience if provided if (expectedAudience && payload.aud !== expectedAudience) { return null; }
// Check nonce if provided if (expectedNonce && payload.nonce !== expectedNonce) { return null; }
// Check expiration const now = Math.floor(Date.now() / 1000); if (typeof payload.exp !== 'number' || payload.exp < now) { return null; }
return payload as unknown as IDTokenClaims; } catch { return null; } }
/** * Sign a JWT */ private signJwt( payload: Record<string, unknown>, signingKey: { kid: string; privateKey: crypto.KeyObject; algorithm: 'ES256' | 'RS256' } ): string { const header = { alg: signingKey.algorithm, typ: 'JWT', kid: signingKey.kid, };
const headerB64 = this.base64urlEncode(JSON.stringify(header)); const payloadB64 = this.base64urlEncode(JSON.stringify(payload)); const signingInput = `${headerB64}.${payloadB64}`;
let signature: Buffer; if (signingKey.algorithm === 'ES256') { const sign = crypto.createSign('SHA256'); sign.update(signingInput); const derSignature = sign.sign(signingKey.privateKey); // Convert DER signature to raw r||s format for JWT signature = this.derToRaw(derSignature); } else { const sign = crypto.createSign('SHA256'); sign.update(signingInput); signature = sign.sign(signingKey.privateKey); }
const signatureB64 = this.base64urlEncode(signature); return `${signingInput}.${signatureB64}`; }
/** * Verify a JWT signature and return payload */ private verifyJwt(token: string): Record<string, unknown> | null { const parts = token.split('.'); if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
try { const header = JSON.parse(this.base64urlDecode(headerB64).toString()) as { alg: string; kid?: string; };
// Get the key — if kid is specified, require it to match let key: { privateKey: crypto.KeyObject; algorithm: 'ES256' | 'RS256' } | null = null; if (header.kid) { key = this.keyManager.getKeyByKid(header.kid); // Reject if kid is present but not found (don't fall back) } else { const signingKey = this.keyManager.getSigningKey(); if (signingKey) { key = { privateKey: signingKey.privateKey, algorithm: signingKey.algorithm }; } } if (!key) return null;
// Get public key from private key const publicKey = crypto.createPublicKey(key.privateKey);
const signingInput = `${headerB64}.${payloadB64}`; const signature = this.base64urlDecode(signatureB64);
let isValid: boolean; if (header.alg === 'ES256') { // Convert raw r||s signature back to DER for verification const derSignature = this.rawToDer(signature); const verify = crypto.createVerify('SHA256'); verify.update(signingInput); isValid = verify.verify(publicKey, derSignature); } else { const verify = crypto.createVerify('SHA256'); verify.update(signingInput); isValid = verify.verify(publicKey, signature); }
if (!isValid) return null;
return JSON.parse(this.base64urlDecode(payloadB64).toString()) as Record<string, unknown>; } catch { return null; } }
/** * Compute at_hash for ID token */ private computeAtHash(accessToken: string, algorithm: 'ES256' | 'RS256'): string { const hashAlg = algorithm === 'ES256' ? 'sha256' : 'sha256'; const hash = crypto.createHash(hashAlg).update(accessToken).digest(); // Take the left half of the hash const leftHalf = hash.subarray(0, hash.length / 2); return this.base64urlEncode(leftHalf); }
/** * Convert DER-encoded ECDSA signature to raw r||s format */ private derToRaw(derSignature: Buffer): Buffer { // DER format: 0x30 [total-length] 0x02 [r-length] [r] 0x02 [s-length] [s] let offset = 2; // Skip 0x30 and total length
// Read r if (derSignature[offset] !== 0x02) throw new Error('Invalid DER signature'); offset++; const rLength = derSignature[offset]; offset++; let r = derSignature.subarray(offset, offset + rLength); offset += rLength;
// Read s if (derSignature[offset] !== 0x02) throw new Error('Invalid DER signature'); offset++; const sLength = derSignature[offset]; offset++; let s = derSignature.subarray(offset, offset + sLength);
// Remove leading zeros and pad to 32 bytes if (r.length > 32) r = r.subarray(r.length - 32); if (s.length > 32) s = s.subarray(s.length - 32);
const raw = Buffer.alloc(64); r.copy(raw, 32 - r.length); s.copy(raw, 64 - s.length);
return raw; }
/** * Convert raw r||s format to DER-encoded ECDSA signature */ private rawToDer(rawSignature: Buffer): Buffer { if (rawSignature.length !== 64) throw new Error('Invalid raw signature length');
let r = rawSignature.subarray(0, 32); let s = rawSignature.subarray(32, 64);
// Remove leading zeros while (r.length > 1 && r[0] === 0 && (r[1] & 0x80) === 0) { r = r.subarray(1); } while (s.length > 1 && s[0] === 0 && (s[1] & 0x80) === 0) { s = s.subarray(1); }
// Add leading zero if high bit is set (to ensure positive integer) if (r[0] & 0x80) { r = Buffer.concat([Buffer.from([0]), r]); } if (s[0] & 0x80) { s = Buffer.concat([Buffer.from([0]), s]); }
const rLen = r.length; const sLen = s.length; const totalLen = 2 + rLen + 2 + sLen;
const der = Buffer.alloc(2 + totalLen); let offset = 0;
der[offset++] = 0x30; // SEQUENCE der[offset++] = totalLen; der[offset++] = 0x02; // INTEGER der[offset++] = rLen; r.copy(der, offset); offset += rLen; der[offset++] = 0x02; // INTEGER der[offset++] = sLen; s.copy(der, offset);
return der; }
/** * Base64url encode */ private base64urlEncode(data: string | Buffer): string { const buffer = typeof data === 'string' ? Buffer.from(data) : data; return buffer.toString('base64url'); }
/** * Base64url decode */ private base64urlDecode(str: string): Buffer { return Buffer.from(str, 'base64url'); }}