Rotating JWT key pair

This commit is contained in:
Evert Prants 2024-12-09 19:17:02 +02:00
parent 846ba5533f
commit a86b2a346b
Signed by: evert
GPG Key ID: 0960A17F9F40237D
15 changed files with 1382 additions and 61 deletions

View File

@ -0,0 +1,9 @@
CREATE TABLE `jwks` (
`uuid` varchar(36) NOT NULL,
`fingerprint` varchar(64) NOT NULL,
`current` tinyint NOT NULL DEFAULT 1,
`created_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
`expires_at` datetime(6) NOT NULL,
`rotate_at` datetime(6) NOT NULL,
CONSTRAINT `jwks_uuid` PRIMARY KEY(`uuid`)
);

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,13 @@
"when": 1717853591270, "when": 1717853591270,
"tag": "0004_quiet_wolfsbane", "tag": "0004_quiet_wolfsbane",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1733752641589,
"tag": "0005_happy_meltdown",
"breakpoints": true
} }
] ]
} }

View File

@ -38,7 +38,7 @@
"reveal": "Reveal secret", "reveal": "Reveal secret",
"regenerate": "Regenerate secret", "regenerate": "Regenerate secret",
"activated": "Activated", "activated": "Activated",
"verified": "Official", "verified": "First-party application",
"scopes": "Available scopes", "scopes": "Available scopes",
"scopesHint": "The level of access to information you will be needing for this application.", "scopesHint": "The level of access to information you will be needing for this application.",
"grants": "Available grant types", "grants": "Available grant types",

View File

@ -9,19 +9,15 @@ export class ApiUtils {
} }
static async getJsonOrFormBody(request: Request) { static async getJsonOrFormBody(request: Request) {
if (request.headers.get('content-type')?.startsWith('application/json')) { try {
try { if (request.headers.get('content-type')?.startsWith('application/json')) {
const jsonBody = await request.json(); const jsonBody = await request.json();
return jsonBody; return jsonBody;
} catch {
return {};
} }
}
try {
const formBody = await request.formData(); const formBody = await request.formData();
return Object.fromEntries(formBody); return Object.fromEntries(formBody);
} catch (err) { } catch {
return {}; return {};
} }
} }

View File

@ -22,6 +22,11 @@ export class CryptoUtils {
return v4(); return v4();
} }
public static fingerprintPem(pem: string) {
const der = pem.trim().split('\n').slice(1, -1).join('');
return crypto.createHash('sha256').update(der).digest('hex');
}
// https://stackoverflow.com/q/52212430 // https://stackoverflow.com/q/52212430
/** /**
* Symmetric encryption function * Symmetric encryption function

View File

@ -13,6 +13,20 @@ import {
type AnyMySqlColumn type AnyMySqlColumn
} from 'drizzle-orm/mysql-core'; } from 'drizzle-orm/mysql-core';
export const jwks = mysqlTable('jwks', {
uuid: varchar('uuid', { length: 36 }).primaryKey(),
fingerprint: varchar('fingerprint', { length: 64 }).notNull(),
current: tinyint('current').notNull().default(1),
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
expires_at: datetime('expires_at', { mode: 'date', fsp: 6 }).notNull(),
rotate_at: datetime('rotate_at', { mode: 'date', fsp: 6 }).notNull()
});
export type JsonKey = typeof jwks.$inferSelect;
export type NewJsonKey = typeof jwks.$inferInsert;
export const auditLog = mysqlTable('audit_log', { export const auditLog = mysqlTable('audit_log', {
id: int('id').autoincrement().notNull(), id: int('id').autoincrement().notNull(),
action: text('action').notNull(), action: text('action').notNull(),

View File

@ -0,0 +1,38 @@
import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises';
import { dirname, join, resolve } from 'path';
export class FileBackend {
static async fileExists(path: string | string[]) {
try {
await stat(FileBackend.filePath(path));
return true;
} catch {
return false;
}
}
static async saveFile(path: string | string[], contents: Buffer) {
const fullPath = FileBackend.filePath(path);
try {
await mkdir(dirname(fullPath), { recursive: true });
} catch {}
return writeFile(fullPath, contents);
}
static async readFile<T = Buffer>(path: string | string[], encoding?: BufferEncoding) {
return readFile(FileBackend.filePath(path), { encoding }) as T;
}
static async deleteFile(path: string | string[]) {
try {
await unlink(FileBackend.filePath(path));
return true;
} catch {
return false;
}
}
private static filePath(path: string | string[]) {
return resolve(Array.isArray(path) ? join(...path) : path);
}
}

View File

@ -1,18 +1,30 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { readFile } from 'fs/promises';
import { import {
SignJWT, type JWK,
exportJWK, type KeyLike,
importPKCS8, importPKCS8,
importSPKI, importSPKI,
SignJWT,
jwtVerify, jwtVerify,
type JWK, generateKeyPair,
type KeyLike exportPKCS8,
exportSPKI,
exportJWK
} from 'jose'; } from 'jose';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { FileBackend } from './file-backend';
import { DB, jwks, type JsonKey } from './drizzle';
import { CryptoUtils } from './crypto-utils';
const { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER } = env; const { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER } = env;
const ISSUER_EXPIRY = 365 * 24 * 60 * 60 * 1000;
const ISSUER_ROTATE = ISSUER_EXPIRY / 2;
interface AvailableWebKey extends JsonKey {
privateKey: KeyLike;
publicKey: KeyLike;
publicKeyJWK: JWK;
}
/** /**
* Generate JWT keys using the following commands: * Generate JWT keys using the following commands:
@ -20,32 +32,37 @@ const { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER } = env;
* Public: openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem * Public: openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem
*/ */
export class JWT { export class JWT {
static privateKey: KeyLike; private static keys: AvailableWebKey[] = [];
static publicKey: KeyLike;
static jwks: JWK; static getPublicJWKs() {
static jwksKid: string; return this.keys.map((info) => ({
alg: JWT_ALGORITHM,
kid: info.uuid,
...info.publicKeyJWK,
use: 'sig'
}));
}
static async init() { static async init() {
try { const keys = await DB.drizzle.select().from(jwks);
const privateKeyFile = await readFile(join('private', 'jwt.private.pem'), { JWT.keys = await JWT.loadKeys(keys);
encoding: 'utf-8'
}); // No current key or it is time to rotate
const publicKeyFile = await readFile(join('private', 'jwt.public.pem'), { const keyPair = JWT.keys.find(({ current }) => current === 1);
encoding: 'utf-8' if (!keyPair || keyPair.rotate_at.getTime() < Date.now()) {
}); const newKey = await JWT.createKeyPair();
JWT.privateKey = await importPKCS8(privateKeyFile, JWT_ALGORITHM); if (keyPair) {
JWT.publicKey = await importSPKI(publicKeyFile, JWT_ALGORITHM); keyPair.current = 0;
JWT.jwks = await exportJWK(JWT.publicKey); }
JWT.jwksKid = uuidv4({ random: Buffer.from(JWT.jwks.n as string).subarray(0, 16) });
} catch (error) { JWT.keys.push(newKey);
console.error('Failed to initialize the JWT backend:', error);
console.error('OpenID Connect flows will not work!');
} }
} }
static async issue(claims: Record<string, unknown>, subject: string, audience?: string) { static async issue(claims: Record<string, unknown>, subject: string, audience?: string) {
const keyInfo = JWT.getCurrentKey();
const sign = new SignJWT(claims) const sign = new SignJWT(claims)
.setProtectedHeader({ alg: JWT_ALGORITHM }) .setProtectedHeader({ alg: JWT_ALGORITHM, kid: keyInfo.uuid })
.setIssuedAt() .setIssuedAt()
.setSubject(subject) .setSubject(subject)
.setExpirationTime(JWT_EXPIRATION) .setExpirationTime(JWT_EXPIRATION)
@ -55,15 +72,99 @@ export class JWT {
sign.setAudience(audience); sign.setAudience(audience);
} }
return sign.sign(JWT.privateKey); return sign.sign(keyInfo.privateKey);
} }
static async verify(token: string, subject?: string, audience?: string) { static async verify(token: string, subject?: string, audience?: string) {
const { payload } = await jwtVerify(token, JWT.publicKey, { const { payload } = await jwtVerify(
issuer: JWT_ISSUER, token,
subject, (header) => {
audience const foundKey = JWT.keys.find((item) => item.uuid === header.kid);
}); if (!foundKey) {
throw new Error('Invalid kid header value');
}
return foundKey.publicKey;
},
{
issuer: JWT_ISSUER,
subject,
audience
}
);
return payload; return payload;
} }
private static getCurrentKey() {
const current = JWT.keys.find(({ current }) => current === 1);
if (!current) {
throw new Error('No current key found');
}
return current;
}
private static async createKeyPair() {
const { privateKey, publicKey } = await generateKeyPair(JWT_ALGORITHM, {
modulusLength: 2048
});
const jwk = await exportJWK(publicKey);
const kid = uuidv4({ random: Buffer.from(jwk.n as string).subarray(0, 16) });
// Save to file backend
const exportedPrivateKey = await exportPKCS8(privateKey);
const exportedPublicKey = await exportSPKI(publicKey);
await FileBackend.saveFile(['private', kid, 'jwt.public.pem'], Buffer.from(exportedPublicKey));
await FileBackend.saveFile(
['private', kid, 'jwt.private.pem'],
Buffer.from(exportedPrivateKey)
);
// Save to database
const fingerprint = CryptoUtils.fingerprintPem(exportedPublicKey);
const entity = {
uuid: kid,
current: 1,
fingerprint,
created_at: new Date(),
rotate_at: new Date(Date.now() + ISSUER_ROTATE),
expires_at: new Date(Date.now() + ISSUER_EXPIRY)
} as JsonKey;
await DB.drizzle.update(jwks).set({ current: 0 });
await DB.drizzle.insert(jwks).values(entity);
// Return full pair information
return {
...entity,
privateKey,
publicKey,
publicKeyJWK: jwk
} as AvailableWebKey;
}
private static async loadKeys(keys: JsonKey[]) {
return Promise.all(
keys.map(async (entity) => {
const publicKey = await FileBackend.readFile<string>(
['private', entity.uuid, 'jwt.public.pem'],
'utf-8'
);
const privateKey = await FileBackend.readFile<string>(
['private', entity.uuid, 'jwt.private.pem'],
'utf-8'
);
const importedPrivateKey = await importPKCS8(privateKey, JWT_ALGORITHM);
const importedPublicKey = await importSPKI(publicKey, JWT_ALGORITHM);
const jwk = await exportJWK(importedPublicKey);
// Return full pair information
return {
...entity,
privateKey: importedPrivateKey,
publicKey: importedPublicKey,
publicKeyJWK: jwk
} as AvailableWebKey;
})
);
}
} }

View File

@ -268,10 +268,10 @@ export class OAuth2AuthorizationController {
} }
// console.debug('Decision check passed'); // console.debug('Decision check passed');
await OAuth2Users.saveConsent(user, client, scope);
} }
await OAuth2Users.saveConsent(user, client, scope);
return OAuth2AuthorizationController.posthandle(url, prehandle); return OAuth2AuthorizationController.posthandle(url, prehandle);
}; };
} }

View File

@ -7,7 +7,7 @@ import {
type User type User
} from '$lib/server/drizzle'; } from '$lib/server/drizzle';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { and, eq } from 'drizzle-orm'; import { and, eq, gt, isNull, or } from 'drizzle-orm';
import { OAuth2Clients } from './client'; import { OAuth2Clients } from './client';
import { OAuth2Tokens } from './tokens'; import { OAuth2Tokens } from './tokens';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
@ -32,7 +32,11 @@ export class OAuth2Users {
and( and(
eq(oauth2Client.client_id, clientId), eq(oauth2Client.client_id, clientId),
eq(oauth2ClientAuthorization.userId, userId), eq(oauth2ClientAuthorization.userId, userId),
eq(oauth2ClientAuthorization.current, 1) eq(oauth2ClientAuthorization.current, 1),
or(
isNull(oauth2ClientAuthorization.expires_at),
gt(oauth2ClientAuthorization.expires_at, new Date())
)
) )
) )
).filter(({ scope }) => { ).filter(({ scope }) => {
@ -54,6 +58,9 @@ export class OAuth2Users {
) )
.limit(1); .limit(1);
// Two week validity for consent
const nextExpiry = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
if (existing) { if (existing) {
const splitScope = OAuth2Clients.splitScope(existing.scope || ''); const splitScope = OAuth2Clients.splitScope(existing.scope || '');
normalized.forEach((entry) => { normalized.forEach((entry) => {
@ -64,7 +71,7 @@ export class OAuth2Users {
await DB.drizzle await DB.drizzle
.update(oauth2ClientAuthorization) .update(oauth2ClientAuthorization)
.set({ scope: OAuth2Clients.joinScope(splitScope), current: 1, expires_at: null }) .set({ scope: OAuth2Clients.joinScope(splitScope), current: 1, expires_at: nextExpiry })
.where(eq(oauth2ClientAuthorization.id, existing.id)); .where(eq(oauth2ClientAuthorization.id, existing.id));
return; return;
} }
@ -72,6 +79,7 @@ export class OAuth2Users {
await DB.drizzle.insert(oauth2ClientAuthorization).values({ await DB.drizzle.insert(oauth2ClientAuthorization).values({
userId: subject.id, userId: subject.id,
clientId: client.id, clientId: client.id,
expires_at: nextExpiry,
scope: OAuth2Clients.joinScope(normalized) scope: OAuth2Clients.joinScope(normalized)
}); });
} }

View File

@ -9,13 +9,14 @@ import {
type User type User
} from './drizzle'; } from './drizzle';
import { Users } from './users'; import { Users } from './users';
import { readFile, stat, unlink, writeFile } from 'fs/promises'; import { readFile, stat } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import * as mime from 'mime-types'; import * as mime from 'mime-types';
import { OAuth2Clients } from './oauth2'; import { OAuth2Clients } from './oauth2';
import { MAX_FILE_SIZE_MB, ALLOWED_IMAGES } from '$lib/constants'; import { MAX_FILE_SIZE_MB, ALLOWED_IMAGES } from '$lib/constants';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import imageSize from 'image-size'; import imageSize from 'image-size';
import { FileBackend } from './file-backend';
export class Uploads { export class Uploads {
static userFallbackImage: Buffer; static userFallbackImage: Buffer;
@ -48,12 +49,7 @@ export class Uploads {
} }
static async removeUpload(subject: Upload) { static async removeUpload(subject: Upload) {
try { await FileBackend.deleteFile([Uploads.uploads, subject.file]);
await unlink(join(Uploads.uploads, subject.file));
} catch {
// ignore unlink error
}
await DB.drizzle.delete(upload).where(eq(upload.id, subject.id)); await DB.drizzle.delete(upload).where(eq(upload.id, subject.id));
} }
@ -142,7 +138,7 @@ export class Uploads {
const newName = `user-${subject.uuid.split('-')[0]}-${Math.floor(Date.now() / 1000)}.${ext}`; const newName = `user-${subject.uuid.split('-')[0]}-${Math.floor(Date.now() / 1000)}.${ext}`;
const buffer = await Uploads.ensureAllowedFile(file); const buffer = await Uploads.ensureAllowedFile(file);
// Write to filesystem // Write to filesystem
await writeFile(join(Uploads.uploads, newName), buffer); await FileBackend.saveFile([Uploads.uploads, newName], buffer);
// Remove old // Remove old
await Uploads.removeAvatar(subject); await Uploads.removeAvatar(subject);
// Update DB // Update DB
@ -163,7 +159,7 @@ export class Uploads {
const newName = `client-${client.client_id.substring(0, 8)}-${Math.floor(Date.now() / 1000)}.${ext}`; const newName = `client-${client.client_id.substring(0, 8)}-${Math.floor(Date.now() / 1000)}.${ext}`;
const buffer = await Uploads.ensureAllowedFile(file); const buffer = await Uploads.ensureAllowedFile(file);
// Write to filesystem // Write to filesystem
await writeFile(join(Uploads.uploads, newName), buffer); await FileBackend.saveFile([Uploads.uploads, newName], buffer);
// Remove old // Remove old
await Uploads.removeClientAvatar(client); await Uploads.removeClientAvatar(client);
// Update DB // Update DB

View File

@ -1,8 +1,7 @@
import { env } from '$env/dynamic/private';
import { JWT } from '$lib/server/jwt'; import { JWT } from '$lib/server/jwt';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
export const GET = async () => export const GET = async () =>
json({ json({
keys: [{ alg: env.JWT_ALGORITHM, kid: JWT.jwksKid, ...JWT.jwks, use: 'sig' }] keys: JWT.getPublicJWKs()
}); });

View File

@ -1,6 +1,5 @@
import { FileBackend } from '$lib/server/file-backend.js';
import { Uploads } from '$lib/server/upload.js'; import { Uploads } from '$lib/server/upload.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET({ params: { uuid } }) { export async function GET({ params: { uuid } }) {
const uploadFile = await Uploads.getAvatarByUuid(uuid); const uploadFile = await Uploads.getAvatarByUuid(uuid);
@ -14,7 +13,7 @@ export async function GET({ params: { uuid } }) {
}); });
} }
const readUpload = await readFile(join(Uploads.uploads, uploadFile.file)); const readUpload = await FileBackend.readFile([Uploads.uploads, uploadFile.file]);
return new Response(readUpload, { return new Response(readUpload, {
status: 200, status: 200,
headers: { headers: {

View File

@ -1,6 +1,5 @@
import { FileBackend } from '$lib/server/file-backend.js';
import { Uploads } from '$lib/server/upload.js'; import { Uploads } from '$lib/server/upload.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET({ params: { uuid } }) { export async function GET({ params: { uuid } }) {
const uploadFile = await Uploads.getClientAvatarById(uuid); const uploadFile = await Uploads.getClientAvatarById(uuid);
@ -14,7 +13,7 @@ export async function GET({ params: { uuid } }) {
}); });
} }
const readUpload = await readFile(join(Uploads.uploads, uploadFile.file)); const readUpload = await FileBackend.readFile([Uploads.uploads, uploadFile.file]);
return new Response(readUpload, { return new Response(readUpload, {
status: 200, status: 200,
headers: { headers: {