From cfa8ff7048cf1496cb89edea27c222c90c783f2b Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Sat, 8 Jun 2024 15:39:52 +0300 Subject: [PATCH] Add device flow --- migrations/0003_round_killmonger.sql | 1 + migrations/meta/0003_snapshot.json | 1064 +++++++++++++++++ migrations/meta/_journal.json | 7 + .../oauth2/OAuth2AuthorizeCard.svelte | 90 ++ .../components/oauth2/OAuth2ScopesCard.svelte | 66 + src/lib/i18n/en/admin.json | 5 +- src/lib/i18n/en/oauth2.json | 10 + src/lib/server/drizzle/schema.ts | 2 +- .../oauth2/controller/device-authorization.ts | 73 ++ src/lib/server/oauth2/controller/token.ts | 24 +- .../server/oauth2/controller/tokens/device.ts | 63 + .../server/oauth2/controller/tokens/index.ts | 1 + src/lib/server/oauth2/error.ts | 147 ++- src/lib/server/oauth2/model/client.ts | 8 +- src/lib/server/oauth2/model/tokens.ts | 94 +- src/lib/types.ts | 10 + .../openid-configuration/+server.ts | 7 +- src/routes/device/+page.server.ts | 75 ++ src/routes/device/+page.svelte | 82 ++ src/routes/oauth2/authorize/+page.svelte | 142 +-- .../oauth2/device_authorization/+server.ts | 23 + src/routes/oauth2/token/+server.ts | 15 +- .../ssoadmin/oauth2/[uuid]/+page.svelte | 9 + 23 files changed, 1807 insertions(+), 211 deletions(-) create mode 100644 migrations/0003_round_killmonger.sql create mode 100644 migrations/meta/0003_snapshot.json create mode 100644 src/lib/components/oauth2/OAuth2AuthorizeCard.svelte create mode 100644 src/lib/components/oauth2/OAuth2ScopesCard.svelte create mode 100644 src/lib/server/oauth2/controller/device-authorization.ts create mode 100644 src/lib/server/oauth2/controller/tokens/device.ts create mode 100644 src/routes/device/+page.server.ts create mode 100644 src/routes/device/+page.svelte create mode 100644 src/routes/oauth2/device_authorization/+server.ts diff --git a/migrations/0003_round_killmonger.sql b/migrations/0003_round_killmonger.sql new file mode 100644 index 0000000..bd93d19 --- /dev/null +++ b/migrations/0003_round_killmonger.sql @@ -0,0 +1 @@ +ALTER TABLE `o_auth2_token` MODIFY COLUMN `type` enum('code','device_code','access_token','refresh_token') NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..5321244 --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,1064 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "3085d3b2-9695-421a-ba90-55681e0a812a", + "prevId": "31269eb0-9aaf-417a-983c-68912644e6dc", + "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": {} + }, + "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": {} + }, + "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 + }, + "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" + ] + } + } + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "o_auth2_token": { + "name": "o_auth2_token", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "enum('code','device_code','access_token','refresh_token')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp()" + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "pcke": { + "name": "pcke", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_token_userId_user_id_fk": { + "name": "o_auth2_token_userId_user_id_fk", + "tableFrom": "o_auth2_token", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_token_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_token_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_token", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + }, + "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": {} + }, + "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" + ] + } + } + }, + "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": {} + }, + "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": {} + } + }, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index fefc4a9..7bb81d5 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1717344670405, "tag": "0002_whole_vivisector", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1717843139528, + "tag": "0003_round_killmonger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/components/oauth2/OAuth2AuthorizeCard.svelte b/src/lib/components/oauth2/OAuth2AuthorizeCard.svelte new file mode 100644 index 0000000..a756a4a --- /dev/null +++ b/src/lib/components/oauth2/OAuth2AuthorizeCard.svelte @@ -0,0 +1,90 @@ + + +
+ {#if user} +
+ +
+ {user.name} + @{user.username} +
+
+
+ {/if} + +
+ +
+ {client.title} + {client.description} + + +
+
+
+
+ + diff --git a/src/lib/components/oauth2/OAuth2ScopesCard.svelte b/src/lib/components/oauth2/OAuth2ScopesCard.svelte new file mode 100644 index 0000000..283ff6f --- /dev/null +++ b/src/lib/components/oauth2/OAuth2ScopesCard.svelte @@ -0,0 +1,66 @@ + + +
+ {#if client.allowedScopes?.length} +

{$t('oauth2.authorize.allowed')}

+
+ {#each client.allowedScopes as scope} + {$t(`oauth2.authorize.scope.${scope}`)} + {/each} +
+ {/if} + + {#if client.disallowedScopes?.length} +

{$t('oauth2.authorize.disallowed')}

+
+ {#each client.disallowedScopes as scope} + {$t(`oauth2.authorize.scope.${scope}`)} + {/each} +
+ {/if} +
+ + diff --git a/src/lib/i18n/en/admin.json b/src/lib/i18n/en/admin.json index c3d5823..bc1cd16 100644 --- a/src/lib/i18n/en/admin.json +++ b/src/lib/i18n/en/admin.json @@ -43,6 +43,7 @@ "scopesHint": "The level of access to information you will be needing for this application.", "grants": "Available grant types", "grantsHint": "The OAuth2 authorization flows you will be using with this application.", + "grantsWarning": "Please note that id_token, implicit and device_code grant types are less secure than other flows, as they do not require a server to authenticate this application with its secret and there is potential for impersonation. You might want to make a separate application for using these flows and give them access to less information.", "created": "Created at", "owner": "Created by", "ownerMe": "that's you!", @@ -88,12 +89,14 @@ "token": "OAuth2 Token endpoint", "introspect": "OAuth2 Introspection endpoint", "userinfo": "User information endpoint (Bearer)", + "device": "OAuth2 Device Authorization endpoint", "openid": "OpenID Connect configuration" }, "grantTexts": { "authorization_code": "Authorization code", "client_credentials": "Client credentials", "refresh_token": "Refresh token", + "device_code": "Device authorization", "implicit": "Implicit token", "id_token": "ID token (OpenID Connect)" }, @@ -102,7 +105,7 @@ "profile": "Basic profile information", "email": "Access user email address", "privileges": "Access user privilege list", - "management": "Manage your application", + "management": "Manage your application (with Client credientials only)", "account": "Change user account settings", "openid": "Get an ID token JWT (OpenID Connect)" }, diff --git a/src/lib/i18n/en/oauth2.json b/src/lib/i18n/en/oauth2.json index a0035bb..b715699 100644 --- a/src/lib/i18n/en/oauth2.json +++ b/src/lib/i18n/en/oauth2.json @@ -18,5 +18,15 @@ "privacy": "Privacy policy", "terms": "Terms of Service" } + }, + "device": { + "title": "Authorize device", + "description": "Please enter the code displayed on your device to proceed.", + "deviceCode": "Device code", + "success": "Success! Please check back to your device for further instructions. You may now close this window." + }, + "errors": { + "noCode": "Please enter a code.", + "invalidCode": "The provided code is invalid or it has expired" } } diff --git a/src/lib/server/drizzle/schema.ts b/src/lib/server/drizzle/schema.ts index 6cd42ea..ce20a34 100644 --- a/src/lib/server/drizzle/schema.ts +++ b/src/lib/server/drizzle/schema.ts @@ -121,7 +121,7 @@ export type NewOAuth2ClientUrl = typeof oauth2ClientUrl.$inferInsert; export const oauth2Token = mysqlTable('o_auth2_token', { id: int('id').autoincrement().notNull(), - type: mysqlEnum('type', ['code', 'access_token', 'refresh_token']).notNull(), + type: mysqlEnum('type', ['code', 'device_code', 'access_token', 'refresh_token']).notNull(), token: text('token').notNull(), scope: text('scope'), expires_at: timestamp('expires_at', { mode: 'date' }) diff --git a/src/lib/server/oauth2/controller/device-authorization.ts b/src/lib/server/oauth2/controller/device-authorization.ts new file mode 100644 index 0000000..6c99377 --- /dev/null +++ b/src/lib/server/oauth2/controller/device-authorization.ts @@ -0,0 +1,73 @@ +import { env } from '$env/dynamic/public'; +import { ApiUtils } from '$lib/server/api-utils'; +import { InvalidClient, InvalidRequest, InvalidScope, UnauthorizedClient } from '../error'; +import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Tokens } from '../model'; +import { OAuth2Response } from '../response'; + +export class OAuth2DeviceAuthorizationController { + static async postRequest({ request }: { request: Request }) { + const body = await ApiUtils.getJsonOrFormBody(request); + + let clientId: string | null = null; + let clientSecret: string | null = null; + if (body.client_id) { + clientId = body.client_id; + clientSecret = body.client_secret; + } 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]; + // console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret); + } + + if (!clientId) { + throw new InvalidClient('client_id body parameter is required'); + } + + const client = await OAuth2Clients.fetchById(clientId); + if (!client || client.activated === 0) { + throw new InvalidClient('Client not found'); + } + + // This flow is the only one we allow to use public access (secret not required) + if (clientSecret && !OAuth2Clients.checkSecret(client, clientSecret)) { + throw new UnauthorizedClient('Invalid client secret'); + } + + if (!OAuth2Clients.checkGrantType(client, 'device_code')) { + throw new UnauthorizedClient('This client does not support grant type device'); + } + + const scope = OAuth2Clients.transformScope(body.scope || ''); + if (!OAuth2Clients.checkScope(client, scope)) { + throw new InvalidScope('Client does not allow access to this scope'); + } + + const issued = await OAuth2DeviceCodes.create(clientId, scope); + + return OAuth2Response.createResponse(200, { + ...issued, + verification_uri: `${env.PUBLIC_URL}/device`, + verification_uri_complete: `${env.PUBLIC_URL}/device?user_code=${issued.user_code}`, + expires_in: OAuth2Tokens.deviceTtl, + interval: OAuth2DeviceCodes.interval + }); + } +} diff --git a/src/lib/server/oauth2/controller/token.ts b/src/lib/server/oauth2/controller/token.ts index b67500a..f76318e 100644 --- a/src/lib/server/oauth2/controller/token.ts +++ b/src/lib/server/oauth2/controller/token.ts @@ -19,7 +19,7 @@ export class OAuth2TokenController { const body = await ApiUtils.getJsonOrFormBody(request); - if (body.client_id && body.client_secret) { + if (body.client_id) { clientId = body.client_id as string; clientSecret = body.client_secret as string; // console.debug('Client credentials parsed from body parameters', clientId, clientSecret); @@ -54,14 +54,27 @@ export class OAuth2TokenController { grantType = body.grant_type as string; // console.debug('Parameter grant_type is', grantType); + // The spec does not allow using this grant type directly by this name, + // but for verification purposes, we will simplify it below. + if (grantType === 'device_code') { + throw new UnsupportedGrantType('Grant type does not match any supported type'); + } + + if (grantType === 'urn:ietf:params:oauth:grant-type:device_code') { + grantType = 'device_code'; + } + 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('Invalid client secret'); + // Device code flow does not require client authentication + if (grantType !== 'device_code' || clientSecret) { + const valid = OAuth2Clients.checkSecret(client, clientSecret); + if (!valid) { + throw new UnauthorizedClient('Invalid client secret'); + } } if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') { @@ -72,6 +85,9 @@ export class OAuth2TokenController { let tokenResponse: OAuth2TokenResponse = {}; try { switch (grantType) { + case 'device_code': + tokenResponse = await tokens.device(client, body.device_code); + break; case 'authorization_code': tokenResponse = await tokens.authorizationCode(client, body.code, body.code_verifier); break; diff --git a/src/lib/server/oauth2/controller/tokens/device.ts b/src/lib/server/oauth2/controller/tokens/device.ts new file mode 100644 index 0000000..9e2bf0e --- /dev/null +++ b/src/lib/server/oauth2/controller/tokens/device.ts @@ -0,0 +1,63 @@ +import type { OAuth2Client } from '$lib/server/drizzle'; +import { Users } from '$lib/server/users'; +import { AccessDenied, AuthorizationPending, ExpiredToken, ServerError } from '../../error'; +import { + OAuth2AccessTokens, + OAuth2Clients, + OAuth2DeviceCodes, + OAuth2Tokens, + OAuth2Users +} from '../../model'; +import type { OAuth2TokenResponse } from '../../response'; + +export async function device(client: OAuth2Client, deviceCode: string) { + const token = await OAuth2DeviceCodes.getByDeviceCode(deviceCode, client.client_id); + if (!token) { + throw new AccessDenied('The Device Code is invalid or the request was denied'); + } + + if (!OAuth2Tokens.checkTTL(token)) { + throw new ExpiredToken('The Device Code is expired'); + } + + if (!token.userId) { + throw new AuthorizationPending('Authorization is pending'); + } + + const cleanScope = OAuth2Clients.transformScope(token.scope || ''); + + const user = await Users.getById(token.userId); + if (!user) { + throw new ServerError('The user was not found'); + } + + const resObj: OAuth2TokenResponse = { + token_type: 'bearer' + }; + + try { + resObj.access_token = await OAuth2AccessTokens.create( + user.id, + client.client_id, + cleanScope, + OAuth2Tokens.tokenTtl + ); + } catch { + throw new ServerError('Failed to call accessTokens.create method'); + } + + if (cleanScope.includes('openid')) { + try { + resObj.id_token = await OAuth2Users.issueIdToken(user, client, cleanScope); + } catch (err) { + console.error(err); + throw new ServerError('Failed to issue an ID token'); + } + } + + resObj.expires_in = OAuth2Tokens.tokenTtl; + + await OAuth2DeviceCodes.removeByCode(deviceCode); + + return resObj; +} diff --git a/src/lib/server/oauth2/controller/tokens/index.ts b/src/lib/server/oauth2/controller/tokens/index.ts index 3a60fa0..ae4115d 100644 --- a/src/lib/server/oauth2/controller/tokens/index.ts +++ b/src/lib/server/oauth2/controller/tokens/index.ts @@ -1,3 +1,4 @@ export * from './authorizationCode'; export * from './clientCredentials'; export * from './refreshToken'; +export * from './device'; diff --git a/src/lib/server/oauth2/error.ts b/src/lib/server/oauth2/error.ts index 0ed241c..8048ebf 100644 --- a/src/lib/server/oauth2/error.ts +++ b/src/lib/server/oauth2/error.ts @@ -1,103 +1,130 @@ export class OAuth2Error extends Error { - public name = 'OAuth2AbstractError'; - public logLevel = 'error'; + public name = 'OAuth2AbstractError'; + public logLevel = 'error'; - constructor( - public code: string, - public message: string, - public status: number - ) { - super(); - Error.captureStackTrace(this, this.constructor); - } + constructor( + public code: string, + public message: string, + public status: number + ) { + super(); + Error.captureStackTrace(this, this.constructor); + } } export class AccessDenied extends OAuth2Error { - public name = 'OAuth2AccessDenied'; - public logLevel = 'info'; + public name = 'OAuth2AccessDenied'; + public logLevel = 'info'; - constructor(msg: string) { - super('access_denied', msg, 403); - } + constructor(msg: string) { + super('access_denied', msg, 403); + } } export class InvalidClient extends OAuth2Error { - public name = 'OAuth2InvalidClient'; - public logLevel = 'info'; + public name = 'OAuth2InvalidClient'; + public logLevel = 'info'; - constructor(msg: string) { - super('invalid_client', msg, 401); - } + constructor(msg: string) { + super('invalid_client', msg, 401); + } } export class InvalidGrant extends OAuth2Error { - public name = 'OAuth2InvalidGrant'; - public logLevel = 'info'; + public name = 'OAuth2InvalidGrant'; + public logLevel = 'info'; - constructor(msg: string) { - super('invalid_grant', msg, 400); - } + constructor(msg: string) { + super('invalid_grant', msg, 400); + } } export class InvalidRequest extends OAuth2Error { - public name = 'OAuth2InvalidRequest'; - public logLevel = 'info'; + public name = 'OAuth2InvalidRequest'; + public logLevel = 'info'; - constructor(msg: string) { - super('invalid_request', msg, 400); - } + constructor(msg: string) { + super('invalid_request', msg, 400); + } } export class InvalidScope extends OAuth2Error { - public name = 'OAuth2InvalidScope'; - public logLevel = 'info'; + public name = 'OAuth2InvalidScope'; + public logLevel = 'info'; - constructor(msg: string) { - super('invalid_scope', msg, 400); - } + constructor(msg: string) { + super('invalid_scope', msg, 400); + } } export class ServerError extends OAuth2Error { - public name = 'OAuth2ServerError'; - public logLevel = 'error'; + public name = 'OAuth2ServerError'; + public logLevel = 'error'; - constructor(msg: string) { - super('server_error', msg, 500); - } + constructor(msg: string) { + super('server_error', msg, 500); + } } export class UnauthorizedClient extends OAuth2Error { - public name = 'OAuth2UnauthorizedClient'; - public logLevel = 'info'; + public name = 'OAuth2UnauthorizedClient'; + public logLevel = 'info'; - constructor(msg: string) { - super('unauthorized_client', msg, 400); - } + constructor(msg: string) { + super('unauthorized_client', msg, 400); + } } export class UnsupportedGrantType extends OAuth2Error { - public name = 'OAuth2UnsupportedGrantType'; - public logLevel = 'info'; + public name = 'OAuth2UnsupportedGrantType'; + public logLevel = 'info'; - constructor(msg: string) { - super('unsupported_grant_type', msg, 400); - } + constructor(msg: string) { + super('unsupported_grant_type', msg, 400); + } } export class UnsupportedResponseType extends OAuth2Error { - public name = 'OAuth2UnsupportedResponseType'; - public logLevel = 'info'; + public name = 'OAuth2UnsupportedResponseType'; + public logLevel = 'info'; - constructor(msg: string) { - super('unsupported_response_type', msg, 400); - } + constructor(msg: string) { + super('unsupported_response_type', msg, 400); + } } export class InteractionRequired extends OAuth2Error { - public name = 'OAuth2InteractionRequired'; - public logLevel = 'info'; + public name = 'OAuth2InteractionRequired'; + public logLevel = 'info'; - constructor(msg: string) { - super('interaction_required', msg, 400); - } + constructor(msg: string) { + super('interaction_required', msg, 400); + } +} + +export class AuthorizationPending extends OAuth2Error { + public name = 'OAuth2AuthorizationPending'; + public logLevel = 'info'; + + constructor(msg: string) { + super('authorization_pending', msg, 400); + } +} + +export class SlowDown extends OAuth2Error { + public name = 'OAuth2SlowDown'; + public logLevel = 'info'; + + constructor(msg: string) { + super('slow_down', msg, 429); + } +} + +export class ExpiredToken extends OAuth2Error { + public name = 'OAuth2ExpiredToken'; + public logLevel = 'info'; + + constructor(msg: string) { + super('expired_token', msg, 400); + } } diff --git a/src/lib/server/oauth2/model/client.ts b/src/lib/server/oauth2/model/client.ts index f07c9ce..2d37ada 100644 --- a/src/lib/server/oauth2/model/client.ts +++ b/src/lib/server/oauth2/model/client.ts @@ -49,6 +49,7 @@ export class OAuth2Clients { public static availableGrantTypes = [ 'authorization_code', 'client_credentials', + 'device_code', 'refresh_token', 'id_token', 'implicit' @@ -66,7 +67,12 @@ export class OAuth2Clients { // Non-administrator capabilities public static implicitGrantTypes = ['id_token', 'implicit']; - public static userSetGrants = ['authorization_code', 'client_credentials', 'refresh_token']; + public static userSetGrants = [ + 'authorization_code', + 'device_code', + 'client_credentials', + 'refresh_token' + ]; public static userSetScopes = [ 'profile', 'picture', diff --git a/src/lib/server/oauth2/model/tokens.ts b/src/lib/server/oauth2/model/tokens.ts index d264d6a..eef4b95 100644 --- a/src/lib/server/oauth2/model/tokens.ts +++ b/src/lib/server/oauth2/model/tokens.ts @@ -6,7 +6,7 @@ import { type OAuth2Token, type User } from '$lib/server/drizzle'; -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, isNull, sql } from 'drizzle-orm'; import { OAuth2Clients } from './client'; import { Users } from '$lib/server/users'; import { CryptoUtils } from '$lib/server/crypto-utils'; @@ -15,6 +15,7 @@ export type CodeChallengeMethod = 'plain' | 'S256'; export enum OAuth2TokenType { CODE = 'code', + DEVICE_CODE = 'device_code', ACCESS_TOKEN = 'access_token', REFRESH_TOKEN = 'refresh_token' } @@ -34,6 +35,7 @@ export interface OAuth2RefreshToken extends OAuth2Token { } export class OAuth2Tokens { + static deviceTtl = 1800; static codeTtl = 3600; static tokenTtl = 604800; static refreshTtl = 3.154e7; @@ -125,11 +127,11 @@ export class OAuth2Tokens { * @param token Access token to check * @returns true if still valid */ - static checkTTL(token: OAuth2AccessToken): boolean { + static checkTTL(token: OAuth2Token): boolean { return new Date().getTime() < new Date(token.expires_at).getTime(); } - static getTTL(token: OAuth2AccessToken): number { + static getTTL(token: OAuth2Token): number { return new Date(token.expires_at).getTime() - Date.now(); } } @@ -325,3 +327,89 @@ export class OAuth2RefreshTokens { return true; } } + +export class OAuth2DeviceCodes { + static interval = 5; + + static async create(clientId: string, scope: string | string[]) { + const client = await OAuth2Clients.fetchById(clientId); + const deviceCode = CryptoUtils.generateString(32); + const userCode = + `${CryptoUtils.generateString(3)}-${CryptoUtils.generateString(3)}`.toUpperCase(); + + const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' '); + const expiresAt = new Date(Date.now() + OAuth2Tokens.deviceTtl * 1000); + + await OAuth2Tokens.insert( + deviceCode, + OAuth2TokenType.DEVICE_CODE, + client, + scopes, + expiresAt, + undefined, + userCode + ); + + return { device_code: deviceCode, user_code: userCode }; + } + + static async getByDeviceCode(code: string, clientId: string) { + const deviceCode = await OAuth2Tokens.fetchByToken(code, OAuth2TokenType.DEVICE_CODE); + if (!deviceCode) { + return undefined; + } + + const client = await OAuth2Clients.fetchById(clientId); + if (client.id !== deviceCode.clientId) { + return undefined; + } + + return deviceCode; + } + + static async getByUserCode(code: string) { + const [retval] = await DB.drizzle + .select({ + id: oauth2Token.id, + clientId: oauth2Token.clientId, + scope: oauth2Token.scope, + expires_at: oauth2Token.expires_at + }) + .from(oauth2Token) + .where( + and( + sql`upper(${oauth2Token.nonce}) = ${code.toUpperCase()}`, + eq(oauth2Token.type, OAuth2TokenType.DEVICE_CODE), + isNull(oauth2Token.userId) + ) + ); + return retval; + } + + static async authorizeByUserCode(user: User, code: string, decision: boolean) { + const retval = await OAuth2DeviceCodes.getByUserCode(code); + if (!retval) { + return false; + } + + if (decision) { + // Associate with user, this marks it as authorized + await DB.drizzle + .update(oauth2Token) + .set({ userId: user.id, expires_at: retval.expires_at }) + .where(eq(oauth2Token.id, retval.id)); + } else { + await DB.drizzle.delete(oauth2Token).where(eq(oauth2Token.id, retval.id)); + } + + return true; + } + + static async removeByCode(code: string) { + const find = await OAuth2Tokens.fetchByToken(code, OAuth2TokenType.DEVICE_CODE); + + await OAuth2Tokens.remove(find); + + return true; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index a59bc59..4fee6b5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -6,6 +6,16 @@ export interface UserSession { privileges?: string[]; } +export interface OAuth2ClientInfo { + links: { url: string; type: string }[]; + client_id: string; + title: string; + description: string | null; + grants: string; + allowedScopes: string[]; + disallowedScopes: string[]; +} + export interface PaginationMeta { rowCount: number; pageSize: number; diff --git a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts index 94e858f..de69aa4 100644 --- a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts +++ b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts @@ -10,6 +10,7 @@ export const GET = async () => jwks_uri: `${publicEnv.PUBLIC_URL}/.well-known/jwks.json`, userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`, introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`, + device_authorization_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/device_authorization`, response_types_supported: ['code', 'id_token'], id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM], subject_types_supported: ['public'], @@ -29,5 +30,9 @@ export const GET = async () => 'email_verified' ], code_challenge_methods_supported: ['plain', 'S256'], - grant_types_supported: ['authorization_code', 'refresh_token'] + grant_types_supported: [ + 'authorization_code', + 'refresh_token', + 'urn:ietf:params:oauth:grant-type:device_code' + ] }); diff --git a/src/routes/device/+page.server.ts b/src/routes/device/+page.server.ts new file mode 100644 index 0000000..30fa6cb --- /dev/null +++ b/src/routes/device/+page.server.ts @@ -0,0 +1,75 @@ +import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Users } from '$lib/server/oauth2/index.js'; +import { Users } from '$lib/server/users'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { RateLimiter } from 'sveltekit-rate-limiter/server'; + +const limiter = new RateLimiter({ + IP: [6, 'm'] +}); + +export const actions = { + default: async (event) => { + const { locals, request, url } = event; + const currentUser = await Users.getBySession(locals.session.data?.user); + if (!currentUser) { + await locals.session.destroy(); + return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`); + } + + const body = await request.formData(); + const code = body.get('code') as string; + if (!body.has('code') || !code) { + if (await limiter.isLimited(event)) throw error(429, "You're doing that too much!"); + return fail(400, { errors: ['noCode'] }); + } + + const token = await OAuth2DeviceCodes.getByUserCode(code); + if (!token?.clientId) { + if (await limiter.isLimited(event)) throw error(429, "You're doing that too much!"); + return fail(404, { errors: ['invalidCode'] }); + } + + const scopes = OAuth2Clients.transformScope(token.scope || ''); + const client = await OAuth2Clients.fetchById(token.clientId); + + // Due to the risk of this API being used with unauthenticated clients, + // implicit consent is not allowed like it is with normal authorization flow. + + if (!body.has('decision')) { + const clientInfo = await OAuth2Clients.authorizeClientInfo(client, scopes); + + return { code, client: clientInfo, errors: [] }; + } + + const consented = body.get('decision') === '1'; + + const success = await OAuth2DeviceCodes.authorizeByUserCode( + currentUser, + body.get('code') as string, + consented + ); + + if (!success) { + return fail(404, { errors: ['invalidCode'] }); + } + + if (consented) { + await OAuth2Users.saveConsent(currentUser, client, scopes); + } + + return { errors: [] }; + } +}; + +export const load = async ({ locals, url }) => { + const userInfo = locals.session.data?.user; + const currentUser = await Users.getBySession(userInfo); + if (!userInfo || !currentUser) { + await locals.session.destroy(); + return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`); + } + + return { + user: userInfo + }; +}; diff --git a/src/routes/device/+page.svelte b/src/routes/device/+page.svelte new file mode 100644 index 0000000..88b860e --- /dev/null +++ b/src/routes/device/+page.svelte @@ -0,0 +1,82 @@ + + + + {#if form?.client} + {$t('oauth2.authorize.title')} "{form.client?.title || ''}" - {env.PUBLIC_SITE_NAME} + {:else} + {$t('oauth2.device.title')} - {env.PUBLIC_SITE_NAME} + {/if} + + + +

{env.PUBLIC_SITE_NAME}

+ + {#if form?.client} +

{$t('oauth2.authorize.title')}

+ + + + + +
+
+ + + +
+ +
+ + + +
+
+ {:else if form && !form.errors?.length} + {$t('oauth2.device.success')} + {:else} +

{$t('oauth2.device.title')}

+

{$t('oauth2.device.description')}

+ +
+ + + + + + + + + +
+ {/if} +
+ + diff --git a/src/routes/oauth2/authorize/+page.svelte b/src/routes/oauth2/authorize/+page.svelte index a7672d5..3c95815 100644 --- a/src/routes/oauth2/authorize/+page.svelte +++ b/src/routes/oauth2/authorize/+page.svelte @@ -1,13 +1,13 @@ @@ -34,55 +34,9 @@

{env.PUBLIC_SITE_NAME}

{$t('oauth2.authorize.title')}

-
- {#if data.user} -
- -
- {data.user.name} - @{data.user.username} -
-
-
- {/if} - -
- -
- {data.client.title} - {data.client.description} + - -
-
-
-
- -
- {#if data.client.allowedScopes?.length} -

{$t('oauth2.authorize.allowed')}

-
- {#each data.client.allowedScopes as scope} - {$t(`oauth2.authorize.scope.${scope}`)} - {/each} -
- {/if} - - {#if data.client.disallowedScopes?.length} -

{$t('oauth2.authorize.disallowed')}

-
- {#each data.client.disallowedScopes as scope} - {$t(`oauth2.authorize.scope.${scope}`)} - {/each} -
- {/if} -
+
@@ -103,98 +57,10 @@ text-align: center; } - .user-client-wrapper { - display: flex; - flex-direction: row; - justify-content: space-evenly; - - & .graphic { - background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27currentColor%27 d=%27M10,4H14V13L17.5,9.5L19.92,11.92L12,19.84L4.08,11.92L6.5,9.5L10,13V4Z%27 /%3E%3C/svg%3E'); - transform: rotate(-90deg); - width: 80px; - height: 80px; - opacity: 0.4; - margin: 20px; - flex-shrink: 0; - } - - & .card { - width: 40%; - } - - @media screen and (max-width: 768px) { - flex-direction: column; - align-items: center; - - & .graphic { - transform: none; - } - - & .card { - width: 100%; - } - } - } - .card-inner { - display: flex; - flex-direction: column; - height: 100%; - } - .card-display-name { - font-size: 1.25rem; - font-weight: 700; - } - .card-links { - display: flex; - flex-direction: column; - margin-top: auto; - } - .scope { - max-width: 600px; - margin: 2rem auto; - } - - .scope-list { - display: flex; - flex-direction: column; - } - - .scope-list-allowed { - margin-bottom: 1rem; - - & .scope-list-item::before { - content: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27%2300f000%27 d=%27M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z%27 /%3E%3C/svg%3E'); - } - } - - .scope-list-disallowed .scope-list-item::before { - content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' style='width:24px;height:24px' viewBox='0 0 24 24'%3E%3Cpath fill='%23f00000' d='M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z' /%3E%3C/svg%3E"); - } - - .scope-list-item { - display: flex; - align-items: center; - font-weight: 700; - - &::before { - display: block; - width: 32px; - height: 32px; - background-position: center; - background-repeat: no-repeat; - margin: 4px; - flex-shrink: 0; - } - } - .decision { display: flex; flex-direction: column; align-items: center; gap: 1rem; } - - :global(html[theme-base='dark']) .user-client-wrapper .graphic { - filter: invert(); - } diff --git a/src/routes/oauth2/device_authorization/+server.ts b/src/routes/oauth2/device_authorization/+server.ts new file mode 100644 index 0000000..453a1e6 --- /dev/null +++ b/src/routes/oauth2/device_authorization/+server.ts @@ -0,0 +1,23 @@ +import { OAuth2Error } from '$lib/server/oauth2/error.js'; +import { OAuth2Response } from '$lib/server/oauth2/response.js'; +import { OAuth2DeviceAuthorizationController } from '$lib/server/oauth2/controller/device-authorization.js'; +import { RateLimiter } from 'sveltekit-rate-limiter/server'; +import { error } from '@sveltejs/kit'; + +const limiter = new RateLimiter({ + IP: [6, 'm'] +}); + +export const POST = async (event) => { + const { request, url } = event; + if (await limiter.isLimited(event)) error(429, "You're doing that too much!"); + + try { + return await OAuth2DeviceAuthorizationController.postRequest({ request }); + } catch (error) { + if (error instanceof OAuth2Error) { + return OAuth2Response.error(url, error); + } + throw error; + } +}; diff --git a/src/routes/oauth2/token/+server.ts b/src/routes/oauth2/token/+server.ts index 4eb388d..1dfc380 100644 --- a/src/routes/oauth2/token/+server.ts +++ b/src/routes/oauth2/token/+server.ts @@ -1,9 +1,20 @@ -import { OAuth2Error } from '$lib/server/oauth2/error.js'; +import { OAuth2Error, SlowDown } from '$lib/server/oauth2/error.js'; import { OAuth2Response } from '$lib/server/oauth2/response.js'; import { OAuth2TokenController } from '$lib/server/oauth2/controller/token.js'; +import { RateLimiter } from 'sveltekit-rate-limiter/server'; + +const limiter = new RateLimiter({ + IP: [15, 'm'] +}); + +export const POST = async (event) => { + const { request, url } = event; -export const POST = async ({ request, url }) => { try { + if (await limiter.isLimited(event)) { + throw new SlowDown('Please, slow down!'); + } + return await OAuth2TokenController.postHandler({ request, url }); } catch (error) { if (error instanceof OAuth2Error) { diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte index 3f37815..f8a68fd 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte @@ -245,6 +245,7 @@

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

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

+

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

@@ -363,6 +364,14 @@ > +
  • + {$t('admin.oauth2.apis.device')} - + {env.PUBLIC_URL}/oauth2/device_authorization +
  • {$t('admin.oauth2.apis.userinfo')} -