Files
cryptowallet/apps/api/src/services/jwt.service.ts
2026-05-03 20:01:58 +03:00

149 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as jose from 'jose';
import { env } from '../config/env';
import { fetchVaultKV2 } from '../config/vault';
import { logger } from '../lib/logger';
export interface AccessTokenPayload {
sub: string;
type: string;
sid: string;
iat: number;
nbf: number;
exp: number;
iss?: string;
aud?: string;
}
export interface AuthContext {
userId: string;
sid: string;
token: AccessTokenPayload;
}
type KeyType = Awaited<ReturnType<typeof jose.importSPKI>>;
// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS*
// (HS — симметричные, могли бы быть подставлены через algorithm confusion).
const ALLOWED_ALGORITHMS = new Set(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA', 'PS256', 'PS384', 'PS512']);
if (!ALLOWED_ALGORITHMS.has(env.jwt.algorithm)) {
throw new Error(`JWT_ALGORITHM "${env.jwt.algorithm}" not allowed. Use one of: ${[...ALLOWED_ALGORITHMS].join(', ')}`);
}
// Live key store — атомарно подменяется через swapKeyMap()
let keyMap: Map<string, KeyType> = new Map();
export function swapKeyMap(newMap: Map<string, KeyType>): void {
keyMap = newMap;
}
export function getKeyMapSize(): number {
return keyMap.size;
}
/**
* Pre-fetch JWT public keys from Vault, не мутируя глобальный keyMap.
* Возвращает новую Map для атомарного swap'а.
*/
export async function fetchJwtKeysFromVault(
vaultAddr: string,
vaultToken: string,
mount: string,
kidPath: string,
kidsPrefix: string,
): Promise<Map<string, KeyType>> {
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
if (!kidData) {
throw new Error('Failed to read JWT kid config from Vault');
}
const kids: string[] = [];
if (kidData.active) kids.push(kidData.active);
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
if (kids.length === 0) {
throw new Error('No active/previous kids found in Vault');
}
const next = new Map<string, KeyType>();
for (const kid of kids) {
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
if (!kidSecret?.public_key) {
logger.warn(`No public_key found for kid=${kid}`);
continue;
}
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
next.set(kid, key);
}
if (next.size === 0) {
throw new Error('No public keys could be loaded from Vault');
}
return next;
}
export async function verifyAccessToken(token: string): Promise<AuthContext> {
let payload: jose.JWTPayload;
try {
const header = jose.decodeProtectedHeader(token);
const kid = header.kid;
if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) {
throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 });
}
const key = keyMap.get(kid);
if (!key) {
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
}
// Двойная защита от algorithm confusion: проверяем точное совпадение
if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) {
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
}
const verifyOptions: jose.JWTVerifyOptions = {
algorithms: [env.jwt.algorithm],
clockTolerance: 10,
};
if (env.jwt.issuer) verifyOptions.issuer = env.jwt.issuer;
if (env.jwt.audience) verifyOptions.audience = env.jwt.audience;
const result = await jose.jwtVerify(token, key, verifyOptions);
payload = result.payload;
} catch (err: any) {
if (err.status === 401) throw err;
if (err.code === 'ERR_JWT_EXPIRED') {
throw Object.assign(new Error('Token expired'), { status: 401 });
}
throw Object.assign(new Error('Invalid token'), { status: 401 });
}
if (payload.type !== 'access') {
throw Object.assign(new Error('Invalid token type'), { status: 401 });
}
if (!payload.sub || !payload.sid) {
throw Object.assign(new Error('Missing token claims'), { status: 401 });
}
return {
userId: payload.sub,
sid: payload.sid as string,
token: {
sub: payload.sub,
type: payload.type as string,
sid: payload.sid as string,
iat: payload.iat!,
nbf: payload.nbf!,
exp: payload.exp!,
iss: payload.iss,
aud: typeof payload.aud === 'string' ? payload.aud : undefined,
},
};
}