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,
"tag": "0004_quiet_wolfsbane",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1733752641589,
"tag": "0005_happy_meltdown",
"breakpoints": true
}
]
}

View File

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

View File

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

View File

@ -22,6 +22,11 @@ export class CryptoUtils {
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
/**
* Symmetric encryption function

View File

@ -13,6 +13,20 @@ import {
type AnyMySqlColumn
} 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', {
id: int('id').autoincrement().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 { readFile } from 'fs/promises';
import {
SignJWT,
exportJWK,
type JWK,
type KeyLike,
importPKCS8,
importSPKI,
SignJWT,
jwtVerify,
type JWK,
type KeyLike
generateKeyPair,
exportPKCS8,
exportSPKI,
exportJWK
} from 'jose';
import { join } from 'path';
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 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:
@ -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
*/
export class JWT {
static privateKey: KeyLike;
static publicKey: KeyLike;
static jwks: JWK;
static jwksKid: string;
private static keys: AvailableWebKey[] = [];
static getPublicJWKs() {
return this.keys.map((info) => ({
alg: JWT_ALGORITHM,
kid: info.uuid,
...info.publicKeyJWK,
use: 'sig'
}));
}
static async init() {
try {
const privateKeyFile = await readFile(join('private', 'jwt.private.pem'), {
encoding: 'utf-8'
});
const publicKeyFile = await readFile(join('private', 'jwt.public.pem'), {
encoding: 'utf-8'
});
JWT.privateKey = await importPKCS8(privateKeyFile, JWT_ALGORITHM);
JWT.publicKey = await importSPKI(publicKeyFile, JWT_ALGORITHM);
JWT.jwks = await exportJWK(JWT.publicKey);
JWT.jwksKid = uuidv4({ random: Buffer.from(JWT.jwks.n as string).subarray(0, 16) });
} catch (error) {
console.error('Failed to initialize the JWT backend:', error);
console.error('OpenID Connect flows will not work!');
const keys = await DB.drizzle.select().from(jwks);
JWT.keys = await JWT.loadKeys(keys);
// No current key or it is time to rotate
const keyPair = JWT.keys.find(({ current }) => current === 1);
if (!keyPair || keyPair.rotate_at.getTime() < Date.now()) {
const newKey = await JWT.createKeyPair();
if (keyPair) {
keyPair.current = 0;
}
JWT.keys.push(newKey);
}
}
static async issue(claims: Record<string, unknown>, subject: string, audience?: string) {
const keyInfo = JWT.getCurrentKey();
const sign = new SignJWT(claims)
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setProtectedHeader({ alg: JWT_ALGORITHM, kid: keyInfo.uuid })
.setIssuedAt()
.setSubject(subject)
.setExpirationTime(JWT_EXPIRATION)
@ -55,15 +72,99 @@ export class JWT {
sign.setAudience(audience);
}
return sign.sign(JWT.privateKey);
return sign.sign(keyInfo.privateKey);
}
static async verify(token: string, subject?: string, audience?: string) {
const { payload } = await jwtVerify(token, JWT.publicKey, {
const { payload } = await jwtVerify(
token,
(header) => {
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;
}
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,9 +268,9 @@ export class OAuth2AuthorizationController {
}
// console.debug('Decision check passed');
}
await OAuth2Users.saveConsent(user, client, scope);
}
return OAuth2AuthorizationController.posthandle(url, prehandle);
};

View File

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

View File

@ -9,13 +9,14 @@ import {
type User
} from './drizzle';
import { Users } from './users';
import { readFile, stat, unlink, writeFile } from 'fs/promises';
import { readFile, stat } from 'fs/promises';
import { join } from 'path';
import * as mime from 'mime-types';
import { OAuth2Clients } from './oauth2';
import { MAX_FILE_SIZE_MB, ALLOWED_IMAGES } from '$lib/constants';
import { error } from '@sveltejs/kit';
import imageSize from 'image-size';
import { FileBackend } from './file-backend';
export class Uploads {
static userFallbackImage: Buffer;
@ -48,12 +49,7 @@ export class Uploads {
}
static async removeUpload(subject: Upload) {
try {
await unlink(join(Uploads.uploads, subject.file));
} catch {
// ignore unlink error
}
await FileBackend.deleteFile([Uploads.uploads, subject.file]);
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 buffer = await Uploads.ensureAllowedFile(file);
// Write to filesystem
await writeFile(join(Uploads.uploads, newName), buffer);
await FileBackend.saveFile([Uploads.uploads, newName], buffer);
// Remove old
await Uploads.removeAvatar(subject);
// 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 buffer = await Uploads.ensureAllowedFile(file);
// Write to filesystem
await writeFile(join(Uploads.uploads, newName), buffer);
await FileBackend.saveFile([Uploads.uploads, newName], buffer);
// Remove old
await Uploads.removeClientAvatar(client);
// Update DB

View File

@ -1,8 +1,7 @@
import { env } from '$env/dynamic/private';
import { JWT } from '$lib/server/jwt';
import { json } from '@sveltejs/kit';
export const GET = async () =>
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 { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET({ params: { 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, {
status: 200,
headers: {

View File

@ -1,6 +1,5 @@
import { FileBackend } from '$lib/server/file-backend.js';
import { Uploads } from '$lib/server/upload.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET({ params: { 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, {
status: 200,
headers: {