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}
-
+