From a86b2a346bb2ca74cfbfde8cb6e66eba1d0937c3 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Mon, 9 Dec 2024 19:17:02 +0200 Subject: [PATCH] Rotating JWT key pair --- migrations/0005_happy_meltdown.sql | 9 + migrations/meta/0005_snapshot.json | 1150 +++++++++++++++++ migrations/meta/_journal.json | 7 + src/lib/i18n/en/admin.json | 2 +- src/lib/server/api-utils.ts | 10 +- src/lib/server/crypto-utils.ts | 5 + src/lib/server/drizzle/schema.ts | 14 + src/lib/server/file-backend.ts | 38 + src/lib/server/jwt.ts | 163 ++- .../server/oauth2/controller/authorization.ts | 4 +- src/lib/server/oauth2/model/user.ts | 14 +- src/lib/server/upload.ts | 14 +- .../jwks.json/+server.ts | 3 +- src/routes/api/avatar/[uuid]/+server.ts | 5 +- .../api/avatar/client/[uuid]/+server.ts | 5 +- 15 files changed, 1382 insertions(+), 61 deletions(-) create mode 100644 migrations/0005_happy_meltdown.sql create mode 100644 migrations/meta/0005_snapshot.json create mode 100644 src/lib/server/file-backend.ts diff --git a/migrations/0005_happy_meltdown.sql b/migrations/0005_happy_meltdown.sql new file mode 100644 index 0000000..c218ab1 --- /dev/null +++ b/migrations/0005_happy_meltdown.sql @@ -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`) +); diff --git a/migrations/meta/0005_snapshot.json b/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..613e962 --- /dev/null +++ b/migrations/meta/0005_snapshot.json @@ -0,0 +1,1150 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "96f8da8b-c450-49fd-a1cb-f8836dc748d5", + "prevId": "7567b993-bf87-45c6-a2a6-7c5a30fc891e", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_ua": { + "name": "actor_ua", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flagged": { + "name": "flagged", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "actorId": { + "name": "actorId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actorId_user_id_fk": { + "name": "audit_log_actorId_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "actorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "document": { + "name": "document", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "document_authorId_user_id_fk": { + "name": "document_authorId_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "jwks": { + "name": "jwks", + "columns": { + "uuid": { + "name": "uuid", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current": { + "name": "current", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotate_at": { + "name": "rotate_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "jwks_uuid": { + "name": "jwks_uuid", + "columns": [ + "uuid" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "o_auth2_client": { + "name": "o_auth2_client", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grants": { + "name": "grants", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('authorization_code')" + }, + "activated": { + "name": "activated", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "verified": { + "name": "verified", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "confidential": { + "name": "confidential", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "pictureId": { + "name": "pictureId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_pictureId_upload_id_fk": { + "name": "o_auth2_client_pictureId_upload_id_fk", + "tableFrom": "o_auth2_client", + "tableTo": "upload", + "columnsFrom": [ + "pictureId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "o_auth2_client_ownerId_user_id_fk": { + "name": "o_auth2_client_ownerId_user_id_fk", + "tableFrom": "o_auth2_client", + "tableTo": "user", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "IDX_e9d16c213910ad57bd05e97b42": { + "name": "IDX_e9d16c213910ad57bd05e97b42", + "columns": [ + "client_id" + ] + } + }, + "checkConstraint": {} + }, + "o_auth2_client_authorization": { + "name": "o_auth2_client_authorization", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "current": { + "name": "current", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_authorization_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_client_authorization_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_client_authorization", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_client_authorization_userId_user_id_fk": { + "name": "o_auth2_client_authorization_userId_user_id_fk", + "tableFrom": "o_auth2_client_authorization", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "o_auth2_client_manager": { + "name": "o_auth2_client_manager", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuerId": { + "name": "issuerId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_manager_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_client_manager_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_client_manager", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_client_manager_userId_user_id_fk": { + "name": "o_auth2_client_manager_userId_user_id_fk", + "tableFrom": "o_auth2_client_manager", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_client_manager_issuerId_user_id_fk": { + "name": "o_auth2_client_manager_issuerId_user_id_fk", + "tableFrom": "o_auth2_client_manager", + "tableTo": "user", + "columnsFrom": [ + "issuerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "o_auth2_client_url": { + "name": "o_auth2_client_url", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('redirect_uri','terms','privacy','website')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_url_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_client_url_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_client_url", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "o_auth2_token": { + "name": "o_auth2_token", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "enum('code','device_code','access_token','refresh_token')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp()" + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "pcke": { + "name": "pcke", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_token_userId_user_id_fk": { + "name": "o_auth2_token_userId_user_id_fk", + "tableFrom": "o_auth2_token", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_token_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_token_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_token", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "privilege": { + "name": "privilege", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automatic": { + "name": "automatic", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "privilege_clientId_o_auth2_client_id_fk": { + "name": "privilege_clientId_o_auth2_client_id_fk", + "tableFrom": "privilege", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "upload": { + "name": "upload", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "original_name": { + "name": "original_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file": { + "name": "file", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaderId": { + "name": "uploaderId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "upload_uploaderId_user_id_fk": { + "name": "upload_uploaderId_user_id_fk", + "tableFrom": "upload", + "tableTo": "user", + "columnsFrom": [ + "uploaderId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "uuid": { + "name": "uuid", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activated": { + "name": "activated", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activity_at": { + "name": "activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp()" + }, + "pictureId": { + "name": "pictureId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_pictureId_upload_id_fk": { + "name": "user_pictureId_upload_id_fk", + "tableFrom": "user", + "tableTo": "upload", + "columnsFrom": [ + "pictureId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "IDX_a95e949168be7b7ece1a2382fe": { + "name": "IDX_a95e949168be7b7ece1a2382fe", + "columns": [ + "uuid" + ] + }, + "IDX_78a916df40e02a9deb1c4b75ed": { + "name": "IDX_78a916df40e02a9deb1c4b75ed", + "columns": [ + "username" + ] + }, + "IDX_e12875dfb3b1d92d7d7c5377e2": { + "name": "IDX_e12875dfb3b1d92d7d7c5377e2", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "user_privileges_privilege": { + "name": "user_privileges_privilege", + "columns": { + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "privilegeId": { + "name": "privilegeId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "IDX_0664a7ff494a1859a09014c0f1": { + "name": "IDX_0664a7ff494a1859a09014c0f1", + "columns": [ + "userId" + ], + "isUnique": false + }, + "IDX_e71171f4ed20bc8564a1819d0b": { + "name": "IDX_e71171f4ed20bc8564a1819d0b", + "columns": [ + "privilegeId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_privileges_privilege_userId_user_id_fk": { + "name": "user_privileges_privilege_userId_user_id_fk", + "tableFrom": "user_privileges_privilege", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_privileges_privilege_privilegeId_privilege_id_fk": { + "name": "user_privileges_privilege_privilegeId_privilege_id_fk", + "tableFrom": "user_privileges_privilege", + "tableTo": "privilege", + "columnsFrom": [ + "privilegeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_token": { + "name": "user_token", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('generic','activation','deactivation','password','login','gdpr','totp','public_key','invite','recovery')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_token_userId_user_id_fk": { + "name": "user_token_userId_user_id_fk", + "tableFrom": "user_token", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index a7434a7..2b52324 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1717853591270, "tag": "0004_quiet_wolfsbane", "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1733752641589, + "tag": "0005_happy_meltdown", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/i18n/en/admin.json b/src/lib/i18n/en/admin.json index fe057a9..2509da8 100644 --- a/src/lib/i18n/en/admin.json +++ b/src/lib/i18n/en/admin.json @@ -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", diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index 063ab73..c2d655a 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -9,19 +9,15 @@ export class ApiUtils { } 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(); return jsonBody; - } catch { - return {}; } - } - try { const formBody = await request.formData(); return Object.fromEntries(formBody); - } catch (err) { + } catch { return {}; } } diff --git a/src/lib/server/crypto-utils.ts b/src/lib/server/crypto-utils.ts index b6d9317..d0c52ae 100644 --- a/src/lib/server/crypto-utils.ts +++ b/src/lib/server/crypto-utils.ts @@ -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 diff --git a/src/lib/server/drizzle/schema.ts b/src/lib/server/drizzle/schema.ts index 559c9c3..c13c517 100644 --- a/src/lib/server/drizzle/schema.ts +++ b/src/lib/server/drizzle/schema.ts @@ -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(), diff --git a/src/lib/server/file-backend.ts b/src/lib/server/file-backend.ts new file mode 100644 index 0000000..b824ec5 --- /dev/null +++ b/src/lib/server/file-backend.ts @@ -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(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); + } +} diff --git a/src/lib/server/jwt.ts b/src/lib/server/jwt.ts index c5e70d7..e698b3d 100644 --- a/src/lib/server/jwt.ts +++ b/src/lib/server/jwt.ts @@ -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, 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, { - issuer: JWT_ISSUER, - subject, - audience - }); + 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( + ['private', entity.uuid, 'jwt.public.pem'], + 'utf-8' + ); + const privateKey = await FileBackend.readFile( + ['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; + }) + ); + } } diff --git a/src/lib/server/oauth2/controller/authorization.ts b/src/lib/server/oauth2/controller/authorization.ts index 5608dca..a608068 100644 --- a/src/lib/server/oauth2/controller/authorization.ts +++ b/src/lib/server/oauth2/controller/authorization.ts @@ -268,10 +268,10 @@ export class OAuth2AuthorizationController { } // console.debug('Decision check passed'); - - await OAuth2Users.saveConsent(user, client, scope); } + await OAuth2Users.saveConsent(user, client, scope); + return OAuth2AuthorizationController.posthandle(url, prehandle); }; } diff --git a/src/lib/server/oauth2/model/user.ts b/src/lib/server/oauth2/model/user.ts index 431c684..c895e14 100644 --- a/src/lib/server/oauth2/model/user.ts +++ b/src/lib/server/oauth2/model/user.ts @@ -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) }); } diff --git a/src/lib/server/upload.ts b/src/lib/server/upload.ts index 1dfcf85..c0f26de 100644 --- a/src/lib/server/upload.ts +++ b/src/lib/server/upload.ts @@ -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 diff --git a/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts b/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts index c5d6841..2bc2b56 100644 --- a/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts +++ b/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts @@ -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() }); diff --git a/src/routes/api/avatar/[uuid]/+server.ts b/src/routes/api/avatar/[uuid]/+server.ts index 557d994..182a12a 100644 --- a/src/routes/api/avatar/[uuid]/+server.ts +++ b/src/routes/api/avatar/[uuid]/+server.ts @@ -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: { diff --git a/src/routes/api/avatar/client/[uuid]/+server.ts b/src/routes/api/avatar/client/[uuid]/+server.ts index 40453f7..3f2cb24 100644 --- a/src/routes/api/avatar/client/[uuid]/+server.ts +++ b/src/routes/api/avatar/client/[uuid]/+server.ts @@ -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: {