74 lines
2.4 KiB
TypeScript
74 lines
2.4 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { exportJWK, importPKCS8, importSPKI, type JWK, type KeyLike, SignJWT } from 'jose';
|
|
import { config } from '../config.js';
|
|
|
|
interface KeyMaterial {
|
|
kid: string;
|
|
privatePem: string;
|
|
publicPem: string;
|
|
privateKey: KeyLike;
|
|
publicJwk: JWK;
|
|
}
|
|
|
|
let cached: KeyMaterial | null = null;
|
|
|
|
async function loadOrGenerate(): Promise<KeyMaterial> {
|
|
if (cached) return cached;
|
|
const dir = path.resolve(config.OAUTH_KEY_DIR);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const privPath = path.join(dir, 'oauth-private.pem');
|
|
const pubPath = path.join(dir, 'oauth-public.pem');
|
|
const kidPath = path.join(dir, 'oauth.kid');
|
|
|
|
let privatePem: string;
|
|
let publicPem: string;
|
|
let kid: string;
|
|
|
|
if (fs.existsSync(privPath) && fs.existsSync(pubPath) && fs.existsSync(kidPath)) {
|
|
privatePem = fs.readFileSync(privPath, 'utf8');
|
|
publicPem = fs.readFileSync(pubPath, 'utf8');
|
|
kid = fs.readFileSync(kidPath, 'utf8').trim();
|
|
} else {
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
|
publicPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
|
|
kid = crypto.randomBytes(8).toString('hex');
|
|
fs.writeFileSync(privPath, privatePem, { mode: 0o600 });
|
|
fs.writeFileSync(pubPath, publicPem, { mode: 0o644 });
|
|
fs.writeFileSync(kidPath, kid);
|
|
}
|
|
|
|
const privateKey = await importPKCS8(privatePem, 'RS256');
|
|
const publicKey = await importSPKI(publicPem, 'RS256');
|
|
const publicJwk: JWK = { ...(await exportJWK(publicKey)), kid, alg: 'RS256', use: 'sig' };
|
|
|
|
cached = { kid, privatePem, publicPem, privateKey, publicJwk };
|
|
return cached;
|
|
}
|
|
|
|
export async function getJWKS(): Promise<{ keys: JWK[] }> {
|
|
const k = await loadOrGenerate();
|
|
return { keys: [k.publicJwk] };
|
|
}
|
|
|
|
export async function signAccessToken(input: {
|
|
subject: string;
|
|
audience: string;
|
|
issuer: string;
|
|
scope?: string;
|
|
ttlSeconds?: number;
|
|
}): Promise<string> {
|
|
const k = await loadOrGenerate();
|
|
const ttl = input.ttlSeconds ?? 3600;
|
|
return await new SignJWT({ scope: input.scope ?? '' })
|
|
.setProtectedHeader({ alg: 'RS256', kid: k.kid, typ: 'JWT' })
|
|
.setIssuer(input.issuer)
|
|
.setSubject(input.subject)
|
|
.setAudience(input.audience)
|
|
.setIssuedAt()
|
|
.setExpirationTime(`${ttl}s`)
|
|
.sign(k.privateKey);
|
|
}
|