From 5faa30d6919a7b1031a78057ad6424e30b5aa500 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Tue, 18 Feb 2025 20:54:09 +0200 Subject: [PATCH] JWT client authentication, OAuth2 PAR support --- migrations/0006_clean_mandrill.sql | 5 + migrations/0007_slim_dexter_bennett.sql | 1 + migrations/meta/0006_snapshot.json | 1179 ++++++++++++++++ migrations/meta/0007_snapshot.json | 1186 +++++++++++++++++ migrations/meta/_journal.json | 14 + src/hooks.server.ts | 2 +- src/lib/i18n/en/admin.json | 20 +- src/lib/server/changesets.ts | 16 + src/lib/server/drizzle/schema.ts | 17 +- .../server/oauth2/controller/authorization.ts | 139 +- .../oauth2/controller/device-authorization.ts | 39 +- src/lib/server/oauth2/controller/index.ts | 1 + .../server/oauth2/controller/introspection.ts | 37 +- .../oauth2/controller/pushed-authorization.ts | 187 +++ src/lib/server/oauth2/controller/token.ts | 44 +- src/lib/server/oauth2/model/client.ts | 79 ++ src/lib/server/oauth2/model/tokens.ts | 112 +- src/lib/server/oauth2/response.ts | 17 +- .../openid-configuration/+server.ts | 2 + src/routes/account/two-factor/+page.server.ts | 4 +- src/routes/login/+page.server.ts | 2 +- src/routes/login/password/+page.server.ts | 4 +- src/routes/oauth2/par/+server.ts | 27 + src/routes/register/+page.server.ts | 2 +- src/routes/ssoadmin/audit/+page.server.ts | 2 +- .../ssoadmin/oauth2/[uuid]/+page.server.ts | 46 +- .../ssoadmin/oauth2/[uuid]/+page.svelte | 29 + .../oauth2/[uuid]/user/[user]/+page.server.ts | 2 +- .../ssoadmin/oauth2/new/+page.server.ts | 2 +- .../ssoadmin/users/[uuid]/+page.server.ts | 2 +- 30 files changed, 3112 insertions(+), 107 deletions(-) create mode 100644 migrations/0006_clean_mandrill.sql create mode 100644 migrations/0007_slim_dexter_bennett.sql create mode 100644 migrations/meta/0006_snapshot.json create mode 100644 migrations/meta/0007_snapshot.json create mode 100644 src/lib/server/oauth2/controller/pushed-authorization.ts create mode 100644 src/routes/oauth2/par/+server.ts diff --git a/migrations/0006_clean_mandrill.sql b/migrations/0006_clean_mandrill.sql new file mode 100644 index 0000000..7bd8ad5 --- /dev/null +++ b/migrations/0006_clean_mandrill.sql @@ -0,0 +1,5 @@ +ALTER TABLE `o_auth2_token` MODIFY COLUMN `type` enum('code','device_code','access_token','refresh_token','par') NOT NULL;--> statement-breakpoint +ALTER TABLE `o_auth2_client` ADD `enforce_par` tinyint DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `o_auth2_token` ADD `state` text;--> statement-breakpoint +ALTER TABLE `o_auth2_token` ADD `grants` text;--> statement-breakpoint +ALTER TABLE `o_auth2_token` ADD `redirect_uri` text; \ No newline at end of file diff --git a/migrations/0007_slim_dexter_bennett.sql b/migrations/0007_slim_dexter_bennett.sql new file mode 100644 index 0000000..3e71487 --- /dev/null +++ b/migrations/0007_slim_dexter_bennett.sql @@ -0,0 +1 @@ +ALTER TABLE `o_auth2_client` ADD `jwks` json; \ No newline at end of file diff --git a/migrations/meta/0006_snapshot.json b/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..437b4ad --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,1179 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "09e116d6-73b4-48ac-979d-89eafe90c2cb", + "prevId": "96f8da8b-c450-49fd-a1cb-f8836dc748d5", + "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 + }, + "enforce_par": { + "name": "enforce_par", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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','par')", + "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 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grants": { + "name": "grants", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "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/0007_snapshot.json b/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..71fadbb --- /dev/null +++ b/migrations/meta/0007_snapshot.json @@ -0,0 +1,1186 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ae1f8d60-30eb-44f7-9f01-8481d6d40644", + "prevId": "09e116d6-73b4-48ac-979d-89eafe90c2cb", + "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 + }, + "enforce_par": { + "name": "enforce_par", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "jwks": { + "name": "jwks", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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','par')", + "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 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grants": { + "name": "grants", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "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 2b52324..e6f76d6 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1733752641589, "tag": "0005_happy_meltdown", "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1739894975346, + "tag": "0006_clean_mandrill", + "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1739896136734, + "tag": "0007_slim_dexter_bennett", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 093095c..2443ebf 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -50,7 +50,7 @@ const handleThemeHook = (async ({ resolve, event }) => { }) satisfies Handle; export const handle = sequence( - csrf(['/oauth2/token', '/oauth2/introspect', '/oauth2/device_authorization']), + csrf(['/oauth2/token', '/oauth2/introspect', '/oauth2/device_authorization', '/oauth2/par']), handleSession({ secret: SESSION_SECRET, cookie: { diff --git a/src/lib/i18n/en/admin.json b/src/lib/i18n/en/admin.json index 2509da8..4d1fef7 100644 --- a/src/lib/i18n/en/admin.json +++ b/src/lib/i18n/en/admin.json @@ -85,12 +85,13 @@ "add": "Add URL" }, "apis": { - "title": "OAuth2 APIs", - "authorize": "OAuth2 Authorization endpoint", - "token": "OAuth2 Token endpoint", - "introspect": "OAuth2 Introspection endpoint", + "title": "OAuth 2.0 APIs", + "authorize": "OAuth 2.0 Authorization endpoint", + "par": "OAuth 2.0 Pushed Authorization Requests endpoint", + "token": "OAuth 2.0 Token endpoint", + "introspect": "OAuth 2.0 Introspection endpoint", "userinfo": "User information endpoint (Bearer)", - "device": "OAuth2 Device Authorization endpoint", + "device": "OAuth 2.0 Device Authorization endpoint", "openid": "OpenID Connect configuration" }, "grantTexts": { @@ -116,6 +117,11 @@ "add": "Invite a new member", "invite": "Invite" }, + "jwks": { + "title": "JSON Web Keys", + "subtitle": "You may use JSON Web Tokens (JWTs) for client authentication (see RFC7523). Enter your public keys here in JWK (JSON Web Key, RFC7517) format. Use JSON array syntax to include multiple, if necessary. Please note that not all claims will be preserved - only the ones required for JWT signature validation will be kept.", + "input": "Enter JSON here" + }, "errors": { "noRedirect": "At least one Redirect URI is required for you to be able to use this application!", "forbidden": "This action is forbidden for this user.", @@ -131,7 +137,9 @@ "invalidEmail": "Invalid email address.", "emailExists": "This email address is already added.", "noFile": "Please upload a file first.", - "tooManyTimes": "You are doing that too much, please, slow down!" + "tooManyTimes": "You are doing that too much, please, slow down!", + "jwksRequired": "JWKs parameter is required!", + "invalidJwks": "Invalid JSON Web Keys provided, please check your input and try again." } }, "audit": { diff --git a/src/lib/server/changesets.ts b/src/lib/server/changesets.ts index 2cf0682..f5ef129 100644 --- a/src/lib/server/changesets.ts +++ b/src/lib/server/changesets.ts @@ -1,4 +1,7 @@ export class Changesets { + /** + * @deprecated use `Changesets.only` + */ static take( fields: (keyof TRes)[], body: FormData | URLSearchParams, @@ -11,4 +14,17 @@ export class Changesets { return accum; }, {}); } + + static only>( + fields: KeyList[], + body: FormData | URLSearchParams, + challenge?: ResultObject + ): ResultObject { + return fields.reduce((accum, field) => { + accum[field] = challenge + ? challenge[field] + : ((body.get(field as string) as string)?.trim() as ResultObject[typeof field]); + return accum; + }, {} as ResultObject); + } } diff --git a/src/lib/server/drizzle/schema.ts b/src/lib/server/drizzle/schema.ts index c13c517..448108f 100644 --- a/src/lib/server/drizzle/schema.ts +++ b/src/lib/server/drizzle/schema.ts @@ -10,8 +10,10 @@ import { timestamp, mysqlEnum, index, - type AnyMySqlColumn + type AnyMySqlColumn, + json } from 'drizzle-orm/mysql-core'; +import type { JWK } from 'jose'; export const jwks = mysqlTable('jwks', { uuid: varchar('uuid', { length: 36 }).primaryKey(), @@ -70,6 +72,8 @@ export const oauth2Client = mysqlTable( activated: tinyint('activated').default(0).notNull(), verified: tinyint('verified').default(0).notNull(), confidential: tinyint('confidential').default(1).notNull(), + enforce_par: tinyint('enforce_par').default(0).notNull(), + jwks: json('jwks').$type(), pictureId: int('pictureId').references(() => upload.id, { onDelete: 'set null' }), ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }), created_at: datetime('created_at', { mode: 'date', fsp: 6 }) @@ -139,7 +143,13 @@ export type NewOAuth2ClientUrl = typeof oauth2ClientUrl.$inferInsert; export const oauth2Token = mysqlTable('o_auth2_token', { id: int('id').autoincrement().notNull(), - type: mysqlEnum('type', ['code', 'device_code', 'access_token', 'refresh_token']).notNull(), + type: mysqlEnum('type', [ + 'code', + 'device_code', + 'access_token', + 'refresh_token', + 'par' + ]).notNull(), token: text('token').notNull(), scope: text('scope'), expires_at: timestamp('expires_at', { mode: 'date' }) @@ -148,6 +158,9 @@ export const oauth2Token = mysqlTable('o_auth2_token', { userId: int('userId').references(() => user.id, { onDelete: 'cascade' }), clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }), nonce: text('nonce'), + state: text('state'), + grants: text('grants'), + redirect_uri: text('redirect_uri'), created_at: datetime('created_at', { mode: 'date', fsp: 6 }) .default(sql`current_timestamp(6)`) .notNull(), diff --git a/src/lib/server/oauth2/controller/authorization.ts b/src/lib/server/oauth2/controller/authorization.ts index 44d7d19..ef8acc2 100644 --- a/src/lib/server/oauth2/controller/authorization.ts +++ b/src/lib/server/oauth2/controller/authorization.ts @@ -1,3 +1,4 @@ +import type { OAuth2Token } from '$lib/server/drizzle'; import { Logger } from '$lib/server/logger'; import type { UserSession } from '../../users'; import { @@ -14,59 +15,83 @@ import { OAuth2AccessTokens, OAuth2Clients, OAuth2Codes, + OAuth2ParCodes, OAuth2Tokens, type CodeChallengeMethod } from '../model'; import { OAuth2Users } from '../model/user'; import { OAuth2Response } from '../response'; +type OAuth2ParToken = OAuth2Token & { + code_challenge?: string; + code_challenge_method?: CodeChallengeMethod; +}; + export class OAuth2AuthorizationController { private static prehandle = async (url: URL, locals: App.Locals) => { - if (!url.searchParams.has('redirect_uri')) { - throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); - } + const requestUri = url.searchParams.get('request_uri'); + let pushedRequest: OAuth2ParToken | undefined = undefined; - const redirectUri = url.searchParams.get('redirect_uri') as string; - Logger.debug('Parameter redirect uri is', redirectUri); - - if (!url.searchParams.has('client_id')) { + const clientId = url.searchParams.get('client_id') as string; + if (!clientId) { throw new InvalidRequest('client_id field is mandatory for authorization endpoint'); } + if (requestUri) { + pushedRequest = await OAuth2ParCodes.getByRequestUri(clientId, requestUri); + + if (!pushedRequest || !OAuth2Tokens.checkTTL(pushedRequest)) { + throw new InvalidRequest('The request_uri is invalid for this client'); + } + + Logger.debug('Taking parameters from Pushed Authorization Request'); + } + + if (!pushedRequest && !url.searchParams.has('redirect_uri')) { + throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); + } + + const redirectUri = ( + pushedRequest ? pushedRequest.redirect_uri : url.searchParams.get('redirect_uri') + ) as string; + Logger.debug('Parameter redirect uri is', redirectUri); + // Check for client_secret (prevent passing it) - if (url.searchParams.has('client_secret')) { + if (!pushedRequest && url.searchParams.has('client_secret')) { throw new InvalidRequest( 'client_secret field should not be passed to the authorization endpoint' ); } - const clientId = url.searchParams.get('client_id') as string; Logger.debug('Parameter client_id is', clientId); - if (!url.searchParams.has('response_type')) { + if (!pushedRequest && !url.searchParams.has('response_type')) { throw new InvalidRequest('response_type field is mandatory for authorization endpoint'); } - const responseType = url.searchParams.get('response_type') as string; - Logger.debug('Parameter response_type is', responseType); + let grantTypes: string[] = pushedRequest?.grants?.split(',') || []; - // Support multiple types - const responseTypes = responseType.split(' '); - let grantTypes: string[] = []; - for (const responseType of responseTypes) { - switch (responseType) { - case 'code': - grantTypes.push('authorization_code'); - break; - case 'token': - grantTypes.push('implicit'); - break; - case 'id_token': - case 'none': - grantTypes.push(responseType); - break; - default: - throw new UnsupportedResponseType('Unknown response_type parameter passed'); + if (!pushedRequest) { + const responseType = url.searchParams.get('response_type') as string; + Logger.debug('Parameter response_type is', responseType); + + // Support multiple types + const responseTypes = responseType.split(' '); + for (const responseType of responseTypes) { + switch (responseType) { + case 'code': + grantTypes.push('authorization_code'); + break; + case 'token': + grantTypes.push('implicit'); + break; + case 'id_token': + case 'none': + grantTypes.push(responseType); + break; + default: + throw new UnsupportedResponseType('Unknown response_type parameter passed'); + } } } @@ -92,6 +117,11 @@ export class OAuth2AuthorizationController { } Logger.debug('redirect_uri check passed'); + if (client.enforce_par === 1 && !pushedRequest) { + throw new InvalidRequest('This client can only authorize Pushed Authorization Requests'); + } + Logger.debug('request_uri check passed'); + // The client needs to support all grant types for (const grantType of grantTypes) { if (grantType !== 'none' && !OAuth2Clients.checkGrantType(client, grantType)) { @@ -100,15 +130,21 @@ export class OAuth2AuthorizationController { } Logger.debug('Grant type check passed'); - const scope = OAuth2Clients.transformScope(url.searchParams.get('scope') as string); + let scope: string | string[] = ( + pushedRequest ? pushedRequest.scope : url.searchParams.get('scope') + ) as string; + scope = OAuth2Clients.transformScope(scope); if (!OAuth2Clients.checkScope(client, scope)) { throw new InvalidScope('Client does not allow access to this scope'); } Logger.debug('Scope check passed'); - const codeChallenge = url.searchParams.get('code_challenge') as string; - const codeChallengeMethod = - (url.searchParams.get('code_challenge_method') as CodeChallengeMethod) || 'plain'; + const codeChallenge = pushedRequest + ? pushedRequest.code_challenge + : (url.searchParams.get('code_challenge') as string); + const codeChallengeMethod = pushedRequest + ? pushedRequest.code_challenge_method + : (url.searchParams.get('code_challenge_method') as CodeChallengeMethod) || 'plain'; if (codeChallengeMethod && !OAuth2Tokens.challengeMethods.includes(codeChallengeMethod)) { throw new InvalidGrant('Invalid code challenge method'); @@ -120,15 +156,23 @@ export class OAuth2AuthorizationController { ); } + const nonce = + (pushedRequest ? pushedRequest.nonce : (url.searchParams.get('nonce') as string)) || + undefined; + const state = + (pushedRequest ? pushedRequest.state : (url.searchParams.get('state') as string)) || + undefined; + return { client, user: locals.user, redirectUri, - responseType, grantTypes, scope, codeChallenge, - codeChallengeMethod + codeChallengeMethod, + nonce, + state }; }; @@ -142,9 +186,15 @@ export class OAuth2AuthorizationController { codeChallenge, codeChallengeMethod, redirectUri, - responseType + nonce, + state }: Awaited> ) => { + const requestUri = url.searchParams.get('request_uri'); + if (requestUri) { + await OAuth2ParCodes.deleteByRequestUri(requestUri); + } + let resObj: Record = {}; for (const grantType of grantTypes) { let data = null; @@ -155,7 +205,7 @@ export class OAuth2AuthorizationController { client.client_id, scope, OAuth2Tokens.codeTtl, - url.searchParams.get('nonce') as string, + nonce, codeChallenge, codeChallengeMethod ); @@ -184,12 +234,7 @@ export class OAuth2AuthorizationController { break; } - data = await OAuth2Users.issueIdToken( - user, - client, - scope, - url.searchParams.get('nonce') as string | undefined - ); + data = await OAuth2Users.issueIdToken(user, client, scope, nonce); resObj = { id_token: data, @@ -205,7 +250,13 @@ export class OAuth2AuthorizationController { } // Return non-code response types as fragment instead of query - return OAuth2Response.responsePlain(url, resObj, redirectUri, responseType !== 'code'); + return OAuth2Response.responsePlain( + url, + resObj, + redirectUri, + grantTypes.every((entry) => entry === 'authorization_code'), + state + ); }; static getRequest = async ({ locals, url }: { locals: App.Locals; url: URL }) => { diff --git a/src/lib/server/oauth2/controller/device-authorization.ts b/src/lib/server/oauth2/controller/device-authorization.ts index fc3e315..3cc4fd9 100644 --- a/src/lib/server/oauth2/controller/device-authorization.ts +++ b/src/lib/server/oauth2/controller/device-authorization.ts @@ -11,9 +11,23 @@ export class OAuth2DeviceAuthorizationController { let clientId: string | null = null; let clientSecret: string | null = null; - if (body.client_id) { + let clientAssertionType: string | null = null; + let clientAssertion: string | null = null; + + if (body.client_secret) { clientId = body.client_id as string; clientSecret = body.client_secret as string; + Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret); + } else if (body.client_assertion) { + clientId = body.client_id as string; + clientAssertionType = body.client_assertion_type; + clientAssertion = body.client_assertion; + Logger.debug( + 'Client assertion credentials parsed from body parameters', + clientId, + clientAssertionType, + clientAssertion + ); } else { if (!request.headers?.has('authorization')) { throw new InvalidRequest('No authorization header passed'); @@ -39,7 +53,7 @@ export class OAuth2DeviceAuthorizationController { } if (!clientId) { - throw new InvalidClient('client_id body parameter is required'); + throw new InvalidRequest('client_id field is mandatory for device authorization endpoint'); } const client = await OAuth2Clients.fetchById(clientId); @@ -47,11 +61,22 @@ export class OAuth2DeviceAuthorizationController { throw new InvalidClient('Client not found'); } - if ( - (client.confidential === 1 || clientSecret) && - !OAuth2Clients.checkSecret(client, clientSecret) - ) { - throw new UnauthorizedClient('Invalid client secret'); + if (client.confidential === 1 || clientSecret || clientAssertion) { + if (clientAssertion && clientAssertionType) { + const valid = await OAuth2Clients.validateClientAssertionAuthentication( + client, + clientAssertionType, + clientAssertion + ); + if (!valid) { + throw new UnauthorizedClient('Invalid client assertion'); + } + } else { + const valid = OAuth2Clients.checkSecret(client, clientSecret); + if (!valid) { + throw new UnauthorizedClient('Invalid client secret'); + } + } } if (!OAuth2Clients.checkGrantType(client, 'device_code')) { diff --git a/src/lib/server/oauth2/controller/index.ts b/src/lib/server/oauth2/controller/index.ts index fb80845..41dc9cc 100644 --- a/src/lib/server/oauth2/controller/index.ts +++ b/src/lib/server/oauth2/controller/index.ts @@ -1,4 +1,5 @@ export * from './authorization'; +export * from './pushed-authorization'; export * from './introspection'; export * from './token'; export * from './bearer'; diff --git a/src/lib/server/oauth2/controller/introspection.ts b/src/lib/server/oauth2/controller/introspection.ts index 52f1bbf..fdacb19 100644 --- a/src/lib/server/oauth2/controller/introspection.ts +++ b/src/lib/server/oauth2/controller/introspection.ts @@ -10,11 +10,23 @@ export class OAuth2IntrospectionController { let clientId: string | null = null; let clientSecret: string | null = null; + let clientAssertionType: string | null = null; + let clientAssertion: string | null = null; - if (body.client_id && body.client_secret) { + if (body.client_secret) { clientId = body.client_id as string; clientSecret = body.client_secret as string; - Logger.debug('Client credentials parsed from body parameters ', clientId, clientSecret); + Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret); + } else if (body.client_assertion) { + clientId = body.client_id as string; + clientAssertionType = body.client_assertion_type; + clientAssertion = body.client_assertion; + Logger.debug( + 'Client assertion credentials parsed from body parameters', + clientId, + clientAssertionType, + clientAssertion + ); } else { if (!request.headers?.has('authorization')) { throw new InvalidRequest('No authorization header passed'); @@ -43,15 +55,30 @@ export class OAuth2IntrospectionController { throw new InvalidRequest('Token not provided in request body'); } + if (!clientId) { + throw new InvalidRequest('client_id field is mandatory for introspection endpoint'); + } + const client = await OAuth2Clients.fetchById(clientId); if (!client || client.activated === 0) { throw new InvalidClient('Client not found'); } - const valid = OAuth2Clients.checkSecret(client, clientSecret); - if (!valid) { - throw new UnauthorizedClient('The client authentication was invalid'); + if (clientAssertion && clientAssertionType) { + const valid = await OAuth2Clients.validateClientAssertionAuthentication( + client, + clientAssertionType, + clientAssertion + ); + if (!valid) { + throw new UnauthorizedClient('Invalid client assertion'); + } + } else { + const valid = OAuth2Clients.checkSecret(client, clientSecret); + if (!valid) { + throw new UnauthorizedClient('Invalid client secret'); + } } const token = await OAuth2AccessTokens.fetchByToken(body.token); diff --git a/src/lib/server/oauth2/controller/pushed-authorization.ts b/src/lib/server/oauth2/controller/pushed-authorization.ts new file mode 100644 index 0000000..f31724b --- /dev/null +++ b/src/lib/server/oauth2/controller/pushed-authorization.ts @@ -0,0 +1,187 @@ +import { ApiUtils } from '$lib/server/api-utils'; +import { Logger } from '$lib/server/logger'; +import { + InvalidRequest, + UnsupportedResponseType, + InvalidClient, + UnauthorizedClient, + InvalidScope, + InvalidGrant +} from '../error'; +import { OAuth2Clients, OAuth2ParCodes, OAuth2Tokens, type CodeChallengeMethod } from '../model'; +import { OAuth2Response } from '../response'; + +export class OAuth2PushedAuthorizationController { + static async postRequest({ request }: { request: Request }) { + const body = await ApiUtils.getJsonOrFormBody(request); + + if (!body.redirect_uri) { + throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); + } + + const redirectUri = body.redirect_uri as string; + Logger.debug('Parameter redirect uri is', redirectUri); + + let clientId: string | null = null; + let clientSecret: string | null = null; + let clientAssertionType: string | null = null; + let clientAssertion: string | null = null; + + if (body.client_secret) { + clientId = body.client_id as string; + clientSecret = body.client_secret as string; + Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret); + } else if (body.client_assertion) { + clientId = body.client_id as string; + clientAssertionType = body.client_assertion_type; + clientAssertion = body.client_assertion; + Logger.debug( + 'Client assertion credentials parsed from body parameters', + clientId, + clientAssertionType, + clientAssertion + ); + } else { + if (!request.headers?.has('authorization')) { + throw new InvalidRequest('No authorization header passed'); + } + + let pieces = (request.headers.get('authorization') as string).split(' ', 2); + if (!pieces || pieces.length !== 2) { + throw new InvalidRequest('Authorization header is corrupted'); + } + + if (pieces[0] !== 'Basic') { + throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`); + } + + pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2); + if (!pieces || pieces.length !== 2) { + throw new InvalidRequest('Authorization header has corrupted data'); + } + + clientId = pieces[0]; + clientSecret = pieces[1]; + Logger.debug('Client credentials parsed from basic auth header:', clientId, clientSecret); + } + + if (!clientId) { + throw new InvalidRequest('client_id field is mandatory for authorization endpoint'); + } + + if (!body.response_type) { + throw new InvalidRequest('response_type field is mandatory for authorization endpoint'); + } + + const responseType = body.response_type as string; + Logger.debug('Parameter response_type is', responseType); + + // Support multiple types + const responseTypes = responseType.split(' '); + let grantTypes: string[] = []; + for (const responseType of responseTypes) { + switch (responseType) { + case 'code': + grantTypes.push('authorization_code'); + break; + case 'token': + grantTypes.push('implicit'); + break; + case 'id_token': + case 'none': + grantTypes.push(responseType); + break; + default: + throw new UnsupportedResponseType('Unknown response_type parameter passed'); + } + } + + // Filter out duplicates + grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index); + + // "None" type cannot be combined with others + if (grantTypes.length > 1 && grantTypes.includes('none')) { + throw new InvalidRequest('Grant type "none" cannot be combined with other grant types'); + } + + Logger.debug('Parameter grant_type is', grantTypes.join(' ')); + + const client = await OAuth2Clients.fetchById(clientId); + if (!client || client.activated === 0) { + throw new InvalidClient('Client not found'); + } + + if (client.confidential === 0) { + throw new InvalidRequest( + 'Non-confidential clients cannot use Pushed Authorization Requests, as the origin of such requests cannot be verified' + ); + } + + if (clientAssertion && clientAssertionType) { + const valid = await OAuth2Clients.validateClientAssertionAuthentication( + client, + clientAssertionType, + clientAssertion + ); + if (!valid) { + throw new UnauthorizedClient('Invalid client assertion'); + } + } else { + const valid = OAuth2Clients.checkSecret(client, clientSecret); + if (!valid) { + throw new UnauthorizedClient('Invalid client secret'); + } + } + Logger.debug('client secret check passed'); + + if (!(await OAuth2Clients.getRedirectUrls(client.client_id))?.length) { + throw new UnsupportedResponseType('The client has not set a redirect uri'); + } else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) { + throw new InvalidRequest('Wrong RedirectUri provided'); + } + Logger.debug('redirect_uri check passed'); + + // The client needs to support all grant types + for (const grantType of grantTypes) { + if (grantType !== 'none' && !OAuth2Clients.checkGrantType(client, grantType)) { + throw new UnauthorizedClient('This client does not support grant type ' + grantType); + } + } + Logger.debug('Grant type check passed'); + + const scope = OAuth2Clients.transformScope(body.scope as string); + if (!OAuth2Clients.checkScope(client, scope)) { + throw new InvalidScope('Client does not allow access to this scope'); + } + Logger.debug('Scope check passed'); + + const codeChallenge = body.code_challenge as string; + const codeChallengeMethod = (body.code_challenge_method as CodeChallengeMethod) || 'plain'; + + if (codeChallengeMethod && !OAuth2Tokens.challengeMethods.includes(codeChallengeMethod)) { + throw new InvalidGrant('Invalid code challenge method'); + } + + if (client.confidential === 0 && !codeChallenge && grantTypes.includes('authorization_code')) { + throw new InvalidGrant( + 'A code_challenge is required for the authorization_code grant in non-confidential clients' + ); + } + + const nonce = body.nonce as string; + const state = body.state as string; + + const response = await OAuth2ParCodes.create( + clientId, + scope, + nonce, + codeChallenge, + codeChallengeMethod, + state, + redirectUri, + grantTypes.join(',') + ); + + return OAuth2Response.createResponse(200, response); + } +} diff --git a/src/lib/server/oauth2/controller/token.ts b/src/lib/server/oauth2/controller/token.ts index 9c9fa36..bc67613 100644 --- a/src/lib/server/oauth2/controller/token.ts +++ b/src/lib/server/oauth2/controller/token.ts @@ -16,14 +16,26 @@ export class OAuth2TokenController { static postHandler = async ({ url, request }: { url: URL; request: Request }) => { let clientId: string | null = null; let clientSecret: string | null = null; + let clientAssertionType: string | null = null; + let clientAssertion: string | null = null; let grantType: string | null = null; const body = await ApiUtils.getJsonOrFormBody(request); - if (body.client_id) { + if (body.client_secret) { clientId = body.client_id as string; clientSecret = body.client_secret as string; - Logger.debug('Client credentials parsed from body parameters', clientId, clientSecret); + Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret); + } else if (body.client_assertion) { + clientId = body.client_id as string; + clientAssertionType = body.client_assertion_type; + clientAssertion = body.client_assertion; + Logger.debug( + 'Client assertion credentials parsed from body parameters', + clientId, + clientAssertionType, + clientAssertion + ); } else { if (!request.headers?.has('authorization')) { throw new InvalidRequest('No authorization header passed'); @@ -65,16 +77,36 @@ export class OAuth2TokenController { grantType = 'device_code'; } + if (!clientId) { + throw new InvalidRequest('client_id field is mandatory for token endpoint'); + } + const client = await OAuth2Clients.fetchById(clientId); if (!client || client.activated === 0) { throw new InvalidClient('Client not found'); } // client_credentials cannot be fetched in public clients. - if (client.confidential === 1 || clientSecret || grantType === 'client_credentials') { - const valid = OAuth2Clients.checkSecret(client, clientSecret); - if (!valid) { - throw new UnauthorizedClient('Invalid client secret'); + if ( + client.confidential === 1 || + clientSecret || + clientAssertion || + grantType === 'client_credentials' + ) { + if (clientAssertion && clientAssertionType) { + const valid = await OAuth2Clients.validateClientAssertionAuthentication( + client, + clientAssertionType, + clientAssertion + ); + if (!valid) { + throw new UnauthorizedClient('Invalid client assertion'); + } + } else { + const valid = OAuth2Clients.checkSecret(client, clientSecret); + if (!valid) { + throw new UnauthorizedClient('Invalid client secret'); + } } } diff --git a/src/lib/server/oauth2/model/client.ts b/src/lib/server/oauth2/model/client.ts index 2a5f311..9a923c7 100644 --- a/src/lib/server/oauth2/model/client.ts +++ b/src/lib/server/oauth2/model/client.ts @@ -18,6 +18,7 @@ import { Uploads } from '$lib/server/upload'; import { UserTokens, Users } from '$lib/server/users'; import type { PaginationMeta } from '$lib/types'; import { and, count, eq, like, or, sql } from 'drizzle-orm'; +import { createLocalJWKSet, exportJWK, importJWK, jwtVerify, type JWK } from 'jose'; export enum OAuth2ClientURLType { REDIRECT_URI = 'redirect_uri', @@ -389,6 +390,48 @@ export class OAuth2Clients { await DB.drizzle.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id)); } + static async updateJwks(client: OAuth2Client, jwksString: string) { + let parsedKeys: JWK[] = []; + + try { + const parsed = JSON.parse(jwksString); + let preParseList: JWK[] = []; + if (Array.isArray(parsed)) { + preParseList = parsed; + } else if (parsed.keys) { + preParseList = parsed.keys; + } else { + preParseList = [parsed]; + } + + // Reassign fields that do not survive the import-export. + // This prevents entering of arbitrary JSON data while also validating the key + parsedKeys = await Promise.all( + preParseList.map(async (entry: JWK) => { + const imported = await importJWK(entry); + const exported = await exportJWK(imported); + (['use', 'kid'] as (keyof JWK)[]).forEach((nKey) => { + exported[nKey] = (entry[nKey] || undefined) as never; + }); + return exported; + }) + ); + + // Deduplicate + parsedKeys = parsedKeys.filter( + (entry, index, array) => + array.findIndex((item) => JSON.stringify(item) === JSON.stringify(entry)) === index + ); + } catch { + throw new Error('Failed to parse JWKs'); + } + + await DB.drizzle + .update(oauth2Client) + .set({ jwks: parsedKeys }) + .where(eq(oauth2Client.id, client.id)); + } + static async getManagers(client: OAuth2Client) { return await DB.drizzle .select({ id: oauth2ClientManager.id, email: user.email }) @@ -480,4 +523,40 @@ export class OAuth2Clients { disallowedScopes }; } + + static async validateClientAssertionAuthentication( + client: OAuth2Client, + assertionType: string, + assertionToken: string + ) { + if ( + assertionType !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' || + !client.jwks + ) { + return false; + } + + const parsedSet = typeof client.jwks === 'string' ? JSON.parse(client.jwks) : client.jwks; + const set = createLocalJWKSet({ keys: parsedSet as JWK[] }); + try { + const { payload } = await jwtVerify(assertionToken, set, { + subject: client.client_id + }); + + // Check audience, token must be intended for our service + const checkAudience = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!checkAudience.some((entry) => entry?.startsWith(env.PUBLIC_URL))) { + return false; + } + + // exp claim is mandatory + if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) { + return false; + } + + return true; + } catch { + return false; + } + } } diff --git a/src/lib/server/oauth2/model/tokens.ts b/src/lib/server/oauth2/model/tokens.ts index eef4b95..23d489c 100644 --- a/src/lib/server/oauth2/model/tokens.ts +++ b/src/lib/server/oauth2/model/tokens.ts @@ -17,7 +17,8 @@ export enum OAuth2TokenType { CODE = 'code', DEVICE_CODE = 'device_code', ACCESS_TOKEN = 'access_token', - REFRESH_TOKEN = 'refresh_token' + REFRESH_TOKEN = 'refresh_token', + PAR = 'par' } export interface OAuth2Code extends OAuth2Token { @@ -39,6 +40,7 @@ export class OAuth2Tokens { static codeTtl = 3600; static tokenTtl = 604800; static refreshTtl = 3.154e7; + static parTtl = 180; static challengeMethods: CodeChallengeMethod[] = ['plain', 'S256']; static async insert( @@ -49,7 +51,10 @@ export class OAuth2Tokens { expiry: Date, user?: User, nonce?: string, - pcke?: string + pcke?: string, + state?: string, + redirectUri?: string, + grants?: string ) { const [retval] = await DB.drizzle.insert(oauth2Token).values({ token, @@ -59,7 +64,10 @@ export class OAuth2Tokens { clientId: client.id, userId: user?.id, nonce, - pcke + pcke, + state, + redirect_uri: redirectUri, + grants }); const [newToken] = await DB.drizzle @@ -134,6 +142,16 @@ export class OAuth2Tokens { static getTTL(token: OAuth2Token): number { return new Date(token.expires_at).getTime() - Date.now(); } + + static readPcke(input: string | undefined | null) { + let codeChallenge: string | undefined; + let codeChallengeMethod: CodeChallengeMethod | undefined; + if (input) { + codeChallengeMethod = OAuth2Tokens.challengeMethods[Number(input.substring(0, 1))]; + codeChallenge = input.substring(2); + } + return { codeChallenge, codeChallengeMethod }; + } } export class OAuth2Codes { @@ -179,12 +197,7 @@ export class OAuth2Codes { return undefined; } - let codeChallenge: string | undefined; - let codeChallengeMethod: CodeChallengeMethod | undefined; - if (find.pcke) { - codeChallengeMethod = OAuth2Tokens.challengeMethods[Number(find.pcke.substring(0, 1))]; - codeChallenge = find.pcke.substring(2); - } + let { codeChallenge, codeChallengeMethod } = OAuth2Tokens.readPcke(find.pcke); const client = await OAuth2Clients.fetchById(find.clientId as number); if (!client || client.activated === 0) { @@ -413,3 +426,84 @@ export class OAuth2DeviceCodes { return true; } } + +export class OAuth2ParCodes { + static issuePrefix = 'urn:ietf:params:oauth:request_uri:'; + static async create( + clientId: string, + scope: string | string[], + nonce?: string, + codeChallenge?: string, + codeChallengeMethod?: CodeChallengeMethod, + state?: string, + redirectUri?: string, + grants?: string + ) { + const client = await OAuth2Clients.fetchById(clientId); + const parCode = CryptoUtils.generateString(32); + + const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' '); + const expiresAt = new Date(Date.now() + OAuth2Tokens.parTtl * 1000); + + const pcke = + codeChallenge && codeChallengeMethod + ? `${OAuth2Tokens.challengeMethods.indexOf(codeChallengeMethod)}:${codeChallenge}` + : undefined; + + await OAuth2Tokens.insert( + parCode, + OAuth2TokenType.PAR, + client, + scopes, + expiresAt, + undefined, + nonce, + pcke, + state, + redirectUri, + grants + ); + + return { + request_uri: `${OAuth2ParCodes.issuePrefix}${parCode}`, + expires_in: OAuth2Tokens.parTtl + }; + } + + static async getByRequestUri(clientId: string, requestUri: string) { + const token = requestUri.startsWith(OAuth2ParCodes.issuePrefix) + ? requestUri.substring(OAuth2ParCodes.issuePrefix.length) + : requestUri; + + const requestParams = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.PAR); + if (!requestParams) { + return undefined; + } + + const client = await OAuth2Clients.fetchById(clientId); + if (client.id !== requestParams.clientId) { + return undefined; + } + + let { codeChallenge, codeChallengeMethod } = OAuth2Tokens.readPcke(requestParams.pcke); + + return { + ...requestParams, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod + }; + } + + static async deleteByRequestUri(requestUri: string) { + if (!requestUri) { + return false; + } + + const token = requestUri.startsWith(OAuth2ParCodes.issuePrefix) + ? requestUri.substring(OAuth2ParCodes.issuePrefix.length) + : requestUri; + const find = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.PAR); + await OAuth2Tokens.remove(find); + return true; + } +} diff --git a/src/lib/server/oauth2/response.ts b/src/lib/server/oauth2/response.ts index 2f8e8f6..87359b4 100644 --- a/src/lib/server/oauth2/response.ts +++ b/src/lib/server/oauth2/response.ts @@ -55,9 +55,10 @@ export class OAuth2Response { url: URL, obj: OAuth2ResponseType, redirectUri?: string, - fragment: boolean = false + fragment: boolean = false, + state?: string ) { - OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment); + OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment, state); return OAuth2Response.createResponse(200, obj); } @@ -66,9 +67,10 @@ export class OAuth2Response { url: URL, obj: OAuth2ResponseType, redirectUri?: string, - fragment: boolean = false + fragment: boolean = false, + state?: string ) { - OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment); + OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment, state); return obj; } @@ -95,14 +97,15 @@ export class OAuth2Response { url: URL, obj: OAuth2ResponseType, redirectUri?: string, - fragment: boolean = false + fragment: boolean = false, + state?: string ) { if (!redirectUri) return; const searchJoinChar = redirectUri.includes('?') ? '&' : '?'; redirectUri += fragment ? '#' : searchJoinChar; - if (url.searchParams.has('state')) { - (obj as OAuth2TokenResponse).state = url.searchParams.get('state') as string; + if (state || url.searchParams.has('state')) { + (obj as OAuth2TokenResponse).state = state || (url.searchParams.get('state') as string); } redirectUri += new URLSearchParams(obj as Record).toString(); diff --git a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts index de69aa4..15a9c93 100644 --- a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts +++ b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts @@ -11,6 +11,8 @@ export const GET = async () => userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`, introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`, device_authorization_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/device_authorization`, + pushed_authorization_request_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/par`, + require_pushed_authorization_requests: false, response_types_supported: ['code', 'id_token'], id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM], subject_types_supported: ['public'], diff --git a/src/routes/account/two-factor/+page.server.ts b/src/routes/account/two-factor/+page.server.ts index 44415dd..6a4f07d 100644 --- a/src/routes/account/two-factor/+page.server.ts +++ b/src/routes/account/two-factor/+page.server.ts @@ -36,7 +36,7 @@ export const actions = { } const body = await request.formData(); - const { challenge, otpCode } = Changesets.take(['challenge', 'otpCode'], body); + const { challenge, otpCode } = Changesets.only(['challenge', 'otpCode'], body); if (!challenge) { return issueActivateChallenge(currentUser); @@ -72,7 +72,7 @@ export const actions = { } const body = await request.formData(); - const { challenge, otpCode } = Changesets.take(['challenge', 'otpCode'], body); + const { challenge, otpCode } = Changesets.only(['challenge', 'otpCode'], body); const userOtp = await TimeOTP.getUserOtp(currentUser); if (!userOtp) { diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index f52a43f..f7a5169 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -45,7 +45,7 @@ export const actions = { } const body = await request.formData(); - const { email, password, challenge, otpCode } = Changesets.take( + const { email, password, challenge, otpCode } = Changesets.only( ['email', 'password', 'challenge', 'otpCode'], body ); diff --git a/src/routes/login/password/+page.server.ts b/src/routes/login/password/+page.server.ts index 54d4270..b1e9584 100644 --- a/src/routes/login/password/+page.server.ts +++ b/src/routes/login/password/+page.server.ts @@ -33,7 +33,7 @@ export const actions = { } const body = await request.formData(); - const { email } = Changesets.take<{ email: string }>(['email'], body); + const { email } = Changesets.only(['email'], body); if (!email || !emailRegex.test(email)) { return { errors: ['invalidEmail'] }; @@ -81,7 +81,7 @@ export const actions = { } const body = await request.formData(); - const { newPassword, repeatPassword } = Changesets.take( + const { newPassword, repeatPassword } = Changesets.only( ['newPassword', 'repeatPassword'], body ); diff --git a/src/routes/oauth2/par/+server.ts b/src/routes/oauth2/par/+server.ts new file mode 100644 index 0000000..eabfbaf --- /dev/null +++ b/src/routes/oauth2/par/+server.ts @@ -0,0 +1,27 @@ +import { OAuth2Error, SlowDown } from '$lib/server/oauth2/error.js'; +import { OAuth2Response } from '$lib/server/oauth2/response.js'; +import { OAuth2PushedAuthorizationController } from '$lib/server/oauth2/controller/pushed-authorization.js'; +import { RateLimiter } from 'sveltekit-rate-limiter/server'; +import { Audit, AuditAction } from '$lib/server/audit'; + +const limiter = new RateLimiter({ + IP: [15, 'm'] +}); + +export const POST = async (event) => { + const { request, url } = event; + + try { + if (await limiter.isLimited(event)) { + await Audit.insertRequest(AuditAction.THROTTLE, event, undefined, `oauth2 par attempt`); + throw new SlowDown('Please, slow down!'); + } + + return await OAuth2PushedAuthorizationController.postRequest({ request }); + } catch (error) { + if (error instanceof OAuth2Error) { + return OAuth2Response.error(url, error); + } + throw error; + } +}; diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index ccc481a..17eb7e2 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -38,7 +38,7 @@ export const actions = { } const body = await request.formData(); - const changes = Changesets.take(fields, body); + const changes = Changesets.only(fields, body); const { username, displayName, diff --git a/src/routes/ssoadmin/audit/+page.server.ts b/src/routes/ssoadmin/audit/+page.server.ts index 70c6940..4b80b82 100644 --- a/src/routes/ssoadmin/audit/+page.server.ts +++ b/src/routes/ssoadmin/audit/+page.server.ts @@ -20,7 +20,7 @@ export const load = async ({ url, parent }) => { AdminUtils.checkPrivileges(userInfo, ['admin:audit']); const actions = url.searchParams.getAll('actions') as AuditAction[]; - const { page, pageSize, user, content, ip, flagged } = Changesets.take( + const { page, pageSize, user, content, ip, flagged } = Changesets.only( ['page', 'pageSize', 'user', 'content', 'ip', 'flagged'], url.searchParams ); diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts b/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts index 78943fd..b7379d0 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts @@ -83,11 +83,10 @@ export const actions = { const { details, fullPrivileges, currentUser } = await getActionData(locals, uuid); const body = await request.formData(); - const { title, description, activated, verified, confidential } = - Changesets.take( - ['title', 'description', 'activated', 'verified', 'confidential'], - body - ); + const { title, description, activated, verified, confidential } = Changesets.only( + ['title', 'description', 'activated', 'verified', 'confidential'], + body + ); if (!!verified && !fullPrivileges) { return fail(403, { errors: ['forbidden'] }); @@ -218,8 +217,9 @@ export const actions = { const { details, currentUser } = await getActionData(locals, uuid); const body = await request.formData(); - const { type, url } = Changesets.take(['type', 'url'], body); - if (!type || !OAuth2Clients.availableUrlTypes.includes(type)) { + const { type, url } = Changesets.only(['type', 'url'], body); + const urlType = type as OAuth2ClientURLType; + if (!type || !OAuth2Clients.availableUrlTypes.includes(urlType)) { return fail(400, { errors: ['invalidUrlType'] }); } @@ -236,7 +236,7 @@ export const actions = { return fail(400, { errors: ['illegalUrl'] }); } - await OAuth2Clients.addUrl(details, type, url); + await OAuth2Clients.addUrl(details, urlType, url); await Audit.insertRequest( AuditAction.OAUTH2_UPDATE, @@ -276,7 +276,7 @@ export const actions = { const { details, currentUser } = await getActionData(locals, uuid); const body = await request.formData(); - const { name } = Changesets.take(['name'], body); + const { name } = Changesets.only(['name'], body); if (!name || !privilegeRegex.test(name)) { return fail(400, { errors: ['invalidPrivilege'] }); @@ -411,7 +411,7 @@ export const actions = { const { currentUser, details, fullPrivileges } = await getActionData(locals, uuid); const body = await request.formData(); - const { email } = Changesets.take(['email'], body); + const { email } = Changesets.only(['email'], body); if (!email || !emailRegex.test(email)) { return fail(400, { errors: ['invalidEmail'] }); @@ -466,6 +466,32 @@ export const actions = { `remove manager\nclient_id=${details.client_id}` ); + return { errors: [] }; + }, + /** + * Update client JWKs + */ + jwks: async ({ locals, request, params: { uuid }, getClientAddress }) => { + const { currentUser, details } = await getActionData(locals, uuid); + const body = await request.formData(); + const value = body.get('jwks') as string; + if (!value) { + return fail(403, { errors: ['jwksRequired'] }); + } + + try { + await OAuth2Clients.updateJwks(details, value); + } catch { + return fail(403, { errors: ['invalidJwks'] }); + } + + await Audit.insertRequest( + AuditAction.OAUTH2_UPDATE, + { request, getClientAddress }, + currentUser, + `update jwks\nclient_id=${details.client_id}` + ); + return { errors: [] }; } }; diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte index 30ebf11..da5743a 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte @@ -45,6 +45,8 @@ let splitScopes = $derived(data.details.scope?.split(' ') || []); let splitGrants = $derived(data.details.grants?.split(' ') || []); let uuidPrefix = $derived(data.details.client_id.split('-')[0] + ':'); + + const jwkPlaceholder = JSON.stringify([{ kty: 'RSA', n: '...', e: 'AQAB' }]); @@ -365,6 +367,14 @@ > +
  • + {$t('admin.oauth2.apis.par')} - + {env.PUBLIC_URL}/oauth2/par +
  • {$t('admin.oauth2.apis.token')} - +

    {$t('admin.oauth2.jwks.title')}

    +

    {@html $t('admin.oauth2.jwks.subtitle')}

    + +
    + + + + + + + + + + + +
    +

    {$t('admin.oauth2.authorizations')}

    {$t('admin.oauth2.authorizationsHint')}

    diff --git a/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.server.ts b/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.server.ts index ab04fc4..bd4ff96 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.server.ts +++ b/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.server.ts @@ -44,7 +44,7 @@ export const actions = { const availablePrivileges = await Users.getAvailablePrivileges(details.id); const body = await request.formData(); - const { privileges } = Changesets.take(['privileges'], body); + const { privileges } = Changesets.only(['privileges'], body); const splitFilter = (privileges || '').split(',').reduce((final, id) => { const privId = Number(id); diff --git a/src/routes/ssoadmin/oauth2/new/+page.server.ts b/src/routes/ssoadmin/oauth2/new/+page.server.ts index 6f8e6ca..81ec328 100644 --- a/src/routes/ssoadmin/oauth2/new/+page.server.ts +++ b/src/routes/ssoadmin/oauth2/new/+page.server.ts @@ -18,7 +18,7 @@ export const actions = { ]); const body = await request.formData(); - const { title, description, redirectUri, confidential } = Changesets.take( + const { title, description, redirectUri, confidential } = Changesets.only( ['title', 'description', 'redirectUri', 'confidential'], body ); diff --git a/src/routes/ssoadmin/users/[uuid]/+page.server.ts b/src/routes/ssoadmin/users/[uuid]/+page.server.ts index 5f6e13a..85fcdf4 100644 --- a/src/routes/ssoadmin/users/[uuid]/+page.server.ts +++ b/src/routes/ssoadmin/users/[uuid]/+page.server.ts @@ -97,7 +97,7 @@ export const actions = { ]); const body = await request.formData(); - const { displayName, email, activated, privileges } = Changesets.take( + const { displayName, email, activated, privileges } = Changesets.only( ['displayName', 'email', 'activated', 'privileges'], body );