From 5760feeff710e8adfda75a6ddbe362eb6adb9f8e Mon Sep 17 00:00:00 2001 From: axyut Date: Tue, 17 Dec 2024 19:40:44 +0545 Subject: [PATCH] feat: goggle login with session and cookie --- .dev.vars.example | 1 + migrations/0007_short_chronomancer.sql | 15 ++ migrations/0008_melted_preak.sql | 1 + migrations/0009_eager_colossus.sql | 25 +++ migrations/meta/0007_snapshot.json | 208 ++++++++++++++++++++++++ migrations/meta/0008_snapshot.json | 215 +++++++++++++++++++++++++ migrations/meta/0009_snapshot.json | 208 ++++++++++++++++++++++++ migrations/meta/_journal.json | 21 +++ package-lock.json | 63 ++++++++ package.json | 4 + public/google_signin_light.png | Bin 0 -> 8055 bytes src/auth/auth.middleware.ts | 77 +++++++++ src/auth/oslo-auth.ts | 136 ++++++++++++++++ src/bindings.ts | 8 +- src/controller/auth/auth.controller.ts | 171 ++++++++++++++++++++ src/controller/auth/google.ts | 205 +++++++++++++++++++++++ src/controller/user/user.controller.ts | 30 ++++ src/controller/user/userCreate.ts | 4 +- src/controller/user/userDelete.ts | 70 ++++---- src/db/oauth.accounts.ts | 22 +++ src/db/schema.ts | 8 +- src/db/sessions.ts | 16 ++ src/db/user.ts | 20 ++- src/index.ts | 8 +- src/routes/routes.ts | 77 ++++++--- src/types.ts | 20 +++ 26 files changed, 1563 insertions(+), 70 deletions(-) create mode 100644 migrations/0007_short_chronomancer.sql create mode 100644 migrations/0008_melted_preak.sql create mode 100644 migrations/0009_eager_colossus.sql create mode 100644 migrations/meta/0007_snapshot.json create mode 100644 migrations/meta/0008_snapshot.json create mode 100644 migrations/meta/0009_snapshot.json create mode 100644 public/google_signin_light.png create mode 100644 src/auth/auth.middleware.ts create mode 100644 src/auth/oslo-auth.ts create mode 100644 src/controller/auth/auth.controller.ts create mode 100644 src/controller/auth/google.ts create mode 100644 src/controller/user/user.controller.ts create mode 100644 src/db/oauth.accounts.ts create mode 100644 src/db/sessions.ts create mode 100644 src/types.ts diff --git a/.dev.vars.example b/.dev.vars.example index 2e9d9fe..4d03634 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -3,5 +3,6 @@ PASSWORD=admin ENVIRONMENT=development GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +ADMINS=aoogleak@gmail.com,boscofficial2021@gmail.com API_DOMAIN=http://localhost:8787 WEB_DOMAIN=http://localhost:4321 \ No newline at end of file diff --git a/migrations/0007_short_chronomancer.sql b/migrations/0007_short_chronomancer.sql new file mode 100644 index 0000000..9fd1f32 --- /dev/null +++ b/migrations/0007_short_chronomancer.sql @@ -0,0 +1,15 @@ +CREATE TABLE `oauth_account` ( + `provider` text NOT NULL, + `provider_user_id` text NOT NULL, + `user_id` text NOT NULL, + PRIMARY KEY(`provider`, `provider_user_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_account_provider_user_id_unique` ON `oauth_account` (`provider_user_id`);--> statement-breakpoint +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/migrations/0008_melted_preak.sql b/migrations/0008_melted_preak.sql new file mode 100644 index 0000000..0a50f8f --- /dev/null +++ b/migrations/0008_melted_preak.sql @@ -0,0 +1 @@ +ALTER TABLE `user` ADD `googleId` text; \ No newline at end of file diff --git a/migrations/0009_eager_colossus.sql b/migrations/0009_eager_colossus.sql new file mode 100644 index 0000000..dc1144c --- /dev/null +++ b/migrations/0009_eager_colossus.sql @@ -0,0 +1,25 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_oauth_account` ( + `provider` text NOT NULL, + `provider_user_id` text NOT NULL, + `user_id` integer NOT NULL, + PRIMARY KEY(`provider`, `provider_user_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_oauth_account`("provider", "provider_user_id", "user_id") SELECT "provider", "provider_user_id", "user_id" FROM `oauth_account`;--> statement-breakpoint +DROP TABLE `oauth_account`;--> statement-breakpoint +ALTER TABLE `__new_oauth_account` RENAME TO `oauth_account`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_account_provider_user_id_unique` ON `oauth_account` (`provider_user_id`);--> statement-breakpoint +CREATE TABLE `__new_session` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `expires_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_session`("id", "user_id", "expires_at") SELECT "id", "user_id", "expires_at" FROM `session`;--> statement-breakpoint +DROP TABLE `session`;--> statement-breakpoint +ALTER TABLE `__new_session` RENAME TO `session`;--> statement-breakpoint +ALTER TABLE `user` DROP COLUMN `googleId`; \ No newline at end of file diff --git a/migrations/meta/0007_snapshot.json b/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..cd6140f --- /dev/null +++ b/migrations/meta/0007_snapshot.json @@ -0,0 +1,208 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "95858942-3ca8-4c65-8f68-7d5b0e71e844", + "prevId": "cbf49183-c7d6-4f90-ad76-f2bf1b4230e6", + "tables": { + "oauth_account": { + "name": "oauth_account", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_account_provider_user_id_unique": { + "name": "oauth_account_provider_user_id_unique", + "columns": [ + "provider_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_account_user_id_user_id_fk": { + "name": "oauth_account_user_id_user_id_fk", + "tableFrom": "oauth_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_account_provider_provider_user_id_pk": { + "columns": [ + "provider", + "provider_user_id" + ], + "name": "oauth_account_provider_provider_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "profilePictureUrl": { + "name": "profilePictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'audience'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..e487b84 --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,215 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9410c0e7-ad33-47c3-b394-a348bd9ae70e", + "prevId": "95858942-3ca8-4c65-8f68-7d5b0e71e844", + "tables": { + "oauth_account": { + "name": "oauth_account", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_account_provider_user_id_unique": { + "name": "oauth_account_provider_user_id_unique", + "columns": [ + "provider_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_account_user_id_user_id_fk": { + "name": "oauth_account_user_id_user_id_fk", + "tableFrom": "oauth_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_account_provider_provider_user_id_pk": { + "columns": [ + "provider", + "provider_user_id" + ], + "name": "oauth_account_provider_provider_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "profilePictureUrl": { + "name": "profilePictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'audience'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0009_snapshot.json b/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..b156e2a --- /dev/null +++ b/migrations/meta/0009_snapshot.json @@ -0,0 +1,208 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "80cc4c67-63d7-4e50-841a-7c083dd0149d", + "prevId": "9410c0e7-ad33-47c3-b394-a348bd9ae70e", + "tables": { + "oauth_account": { + "name": "oauth_account", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_account_provider_user_id_unique": { + "name": "oauth_account_provider_user_id_unique", + "columns": [ + "provider_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_account_user_id_user_id_fk": { + "name": "oauth_account_user_id_user_id_fk", + "tableFrom": "oauth_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_account_provider_provider_user_id_pk": { + "columns": [ + "provider", + "provider_user_id" + ], + "name": "oauth_account_provider_provider_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "profilePictureUrl": { + "name": "profilePictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'audience'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 99d0cd2..b4cc6b7 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -50,6 +50,27 @@ "when": 1732983394432, "tag": "0006_dizzy_blazing_skull", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1734188554369, + "tag": "0007_short_chronomancer", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1734433425660, + "tag": "0008_melted_preak", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1734437311437, + "tag": "0009_eager_colossus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b83f29a..de5cf7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "cloudflare-workers-openapi", "version": "0.0.1", "dependencies": { + "@hono/zod-validator": "^0.4.2", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "arctic": "^2.3.1", "chanfana": "^2.3.0", "drizzle-orm": "^0.36.4", "drizzle-zod": "^0.5.1", @@ -973,6 +977,15 @@ "node": ">=14" } }, + "node_modules/@hono/zod-validator": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.4.2.tgz", + "integrity": "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g==", + "peerDependencies": { + "hono": ">=3.9.0", + "zod": "^3.19.1" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -998,6 +1011,46 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@oslojs/asn1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", + "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", + "dependencies": { + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/binary": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", + "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==" + }, + "node_modules/@oslojs/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", + "dependencies": { + "@oslojs/asn1": "1.0.0", + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==" + }, + "node_modules/@oslojs/jwt": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz", + "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", + "dependencies": { + "@oslojs/encoding": "0.4.1" + } + }, + "node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", + "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==" + }, "node_modules/@types/node": { "version": "20.8.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.3.tgz", @@ -1043,6 +1096,16 @@ "node": ">=0.4.0" } }, + "node_modules/arctic": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/arctic/-/arctic-2.3.1.tgz", + "integrity": "sha512-bnmPYWbPtrQcneG/dmZIdvDeZ7pYhHqd4hYTbOR5LB9f429XLHiE4VnmKmJCPkw+G2CsG689WS+NDwa5UKsigA==", + "dependencies": { + "@oslojs/crypto": "1.0.1", + "@oslojs/encoding": "1.1.0", + "@oslojs/jwt": "0.2.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", diff --git a/package.json b/package.json index c05eb62..6d33d4b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "studio:local": "cross-env LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio" }, "dependencies": { + "@hono/zod-validator": "^0.4.2", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "arctic": "^2.3.1", "chanfana": "^2.3.0", "drizzle-orm": "^0.36.4", "drizzle-zod": "^0.5.1", diff --git a/public/google_signin_light.png b/public/google_signin_light.png new file mode 100644 index 0000000000000000000000000000000000000000..c1e2c5c7d2fd44bf9babaf95f859aaae3f09d7c6 GIT binary patch literal 8055 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 literal 0 HcmV?d00001 diff --git a/src/auth/auth.middleware.ts b/src/auth/auth.middleware.ts new file mode 100644 index 0000000..2d4006c --- /dev/null +++ b/src/auth/auth.middleware.ts @@ -0,0 +1,77 @@ +import type { Context } from "hono"; +import { env } from "hono/adapter"; +import { getCookie, setCookie } from "hono/cookie"; +import { createMiddleware } from "hono/factory"; + +import { AppBindings } from "bindings"; +import { generateSessionToken, validateSessionToken } from "./oslo-auth"; + +export const authMiddleware = createMiddleware(async (c, next) => { + if (c.req.path.startsWith("/auth")) { + return next(); + } + + const originHeader = c.req.header("Origin") ?? c.req.header("origin"); + const hostHeader = c.req.header("Host") ?? c.req.header("X-Forwarded-Host"); + if ( + (!originHeader || + !hostHeader || + !verifyRequestOrigin(originHeader, [hostHeader, env(c).WEB_DOMAIN])) && + env(c).ENVIRONMENT === "production" && + c.req.method !== "GET" + ) { + return new Response(null, { + status: 403, + }); + } + + const sessionToken = getCookie(c, "session"); + if (!sessionToken) { + return new Response("Unauthorized", { status: 401 }); + } + const { session, user } = await validateSessionToken(c, sessionToken); + if (!session || !user) { + return new Response("Unauthorized", { status: 401 }); + } + + // previously session?.fresh checked if the session was created within the last how many days? + // it is now handled in the validateSessionToken function + + c.set("user", user); + // c.set("session", session); + await next(); +}); + +// this is copy pasted from lucia github `https://github.com/lucia-auth/lucia/blob/v3/packages/lucia/src/request.ts` +function verifyRequestOrigin( + origin: string, + allowedDomains: string[] +): boolean { + if (!origin || allowedDomains.length === 0) { + return false; + } + const originHost = safeURL(origin)?.host ?? null; + if (!originHost) { + return false; + } + for (const domain of allowedDomains) { + let host: string | null; + if (domain.startsWith("http://") || domain.startsWith("https://")) { + host = safeURL(domain)?.host ?? null; + } else { + host = safeURL("https://" + domain)?.host ?? null; + } + if (originHost === host) { + return true; + } + } + return false; +} + +function safeURL(url: URL | string): URL | null { + try { + return new URL(url); + } catch { + return null; + } +} diff --git a/src/auth/oslo-auth.ts b/src/auth/oslo-auth.ts new file mode 100644 index 0000000..12af563 --- /dev/null +++ b/src/auth/oslo-auth.ts @@ -0,0 +1,136 @@ +import { sha256 } from "@oslojs/crypto/sha2"; +import { + encodeBase32LowerCaseNoPadding, + encodeHexLowerCase, +} from "@oslojs/encoding"; +import { eq } from "drizzle-orm"; +import type { Context } from "hono"; +import { env } from "hono/adapter"; + +import { AppBindings } from "bindings"; +import { sessionTable } from "../db/sessions"; +import { userTable } from "../db/user"; +import type { + DatabaseUserAttributes, + Session, + SessionValidationResult, +} from "../types"; + +type SessionQueryResult = { + user: DatabaseUserAttributes & { id: string }; + session: { + id: string; + userId: number; + expiresAt: number; + }; +}; + +export function generateSessionToken(): string { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + return encodeBase32LowerCaseNoPadding(bytes); +} + +export async function createSession( + c: Context, + token: string, + userId: number +): Promise { + const db = c.get("db"); + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: Session = { + id: sessionId, + userId, + expiresAt: 60 * 60 * 24 * 30, + }; + + await db.insert(sessionTable).values(session); + return session; +} + +export async function validateSessionToken( + c: Context, + token: string +): Promise { + const db = c.get("db"); + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + + const results = await db + .select({ + session: sessionTable, + user: userTable, + }) + .from(sessionTable) + .where(eq(sessionTable.id, sessionId)) + .innerJoin(userTable, eq(sessionTable.userId, userTable.id)); + + const result = results[0] as SessionQueryResult | undefined; + if (!result) { + return { session: null, user: null }; + } + + const { user, session } = result; + + if (Date.now() >= Date.now() - session.expiresAt) { + await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); + return { session: null, user: null }; + } + + // Extend session if it's close to expiring (15 days before expiration) + if (session.expiresAt < 60 * 60 * 24 * 15) { + const newExpiresAt = 60 * 60 * 24 * 30; + await db + .update(sessionTable) + .set({ + expiresAt: newExpiresAt, + }) + .where(eq(sessionTable.id, session.id)); + + session.expiresAt = newExpiresAt; + } + + return { + session: session as Session, + user: user as DatabaseUserAttributes & { id: string }, + }; +} + +export async function invalidateSession( + c: Context, + sessionId: string +): Promise { + const db = c.get("db"); + await db.delete(sessionTable).where(eq(sessionTable.id, sessionId)); +} + +export function setSessionTokenCookie( + c: Context, + token: string, + expiresAt: number +): void { + if (env(c).ENVIRONMENT === "production") { + c.res.headers.set( + "Set-Cookie", + `session=${token}; HttpOnly; SameSite=Lax; Max-Age=${expiresAt}; Path=/; Secure;` + ); + } else { + c.res.headers.set( + "Set-Cookie", + `session=${token}; HttpOnly; SameSite=Lax; Max-Age=${expiresAt}; Path=/` + ); + } +} + +export function deleteSessionTokenCookie(c: Context): void { + if (env(c).ENVIRONMENT === "production") { + c.res.headers.set( + "Set-Cookie", + `session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure;` + ); + } else { + c.res.headers.set( + "Set-Cookie", + `session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/` + ); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index c2b8bab..f21df1a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1,15 +1,12 @@ import type { Database } from "./db/db"; -import type { InferInsertModel } from "drizzle-orm"; +import type { DatabaseUserAttributes, Session } from "types"; -import type { userTable } from "./db/user"; - -export type DatabaseUserAttributes = InferInsertModel; type Variables = { // with c.var db: Database; user: (DatabaseUserAttributes & { id: string }) | null; - // session: Session | null; + session: Session | null; }; export interface AppBindings { @@ -28,6 +25,7 @@ type Env = { // GITHUB_CLIENT_SECRET: string; GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_SECRET: string; + ADMINS: string; API_DOMAIN: string; WEB_DOMAIN: string; }; diff --git a/src/controller/auth/auth.controller.ts b/src/controller/auth/auth.controller.ts new file mode 100644 index 0000000..4510173 --- /dev/null +++ b/src/controller/auth/auth.controller.ts @@ -0,0 +1,171 @@ +import { zValidator } from "@hono/zod-validator"; +import { generateCodeVerifier, generateState } from "arctic"; +import { Hono } from "hono"; +import { env } from "hono/adapter"; +import { getCookie, setCookie } from "hono/cookie"; +import { z } from "zod"; + +import { + deleteSessionTokenCookie, + invalidateSession, + validateSessionToken, +} from "../../auth/oslo-auth"; +import { AppBindings } from "bindings"; +import { createGoogleSession, getGoogleAuthorizationUrl } from "./google"; + +const AuthController = new Hono() + .get("/logout", async (c) => { + const sessionToken = getCookie(c, "session"); + if (!sessionToken) { + return c.json({ error: "Not logged in" }, 400); + } + + const session = c.get("session"); + if (session) { + await invalidateSession(c, session.id); + } + deleteSessionTokenCookie(c); + + return c.redirect(env(c).API_DOMAIN); + }) + .get( + "/:provider", + zValidator( + "param", + z.object({ provider: z.enum(["github", "google", "apple"]) }) + ), + zValidator( + "query", + z.object({ + redirect: z.enum([ + "http://bosc.org.np", + "https://bosc.org.np", + "http://localhost:4321", + "https://api.bosc.org.np", + "http://api.bosc.org.np", + "http://localhost:8787", + ]), + sessionToken: z.string().optional(), + }) + // .default({ redirect: "http://localhost:8787" }) + ), + async (c) => { + const provider = c.req.valid("param").provider; + const redirect = c.req.valid("query").redirect; + const sessionToken = c.req.valid("query").sessionToken; + setCookie(c, "redirect", redirect, { + httpOnly: true, + maxAge: 60 * 10, + path: "/", + secure: env(c).ENVIRONMENT === "production", + }); + if (sessionToken) { + // validate session using oslo-auth + const { session, user } = await validateSessionToken(c, sessionToken); + if (user) { + // for account linking + setCookie(c, "sessionToken", sessionToken, { + httpOnly: true, + maxAge: 60 * 10, // 10 minutes + path: "/", + secure: env(c).ENVIRONMENT === "production", + }); + } + } + const state = generateState(); + + if (provider === "google") { + const codeVerifier = generateCodeVerifier(); + const url = await getGoogleAuthorizationUrl({ c, state, codeVerifier }); + setCookie(c, "google_oauth_state", state, { + httpOnly: true, + maxAge: 60 * 10, + path: "/", + secure: env(c).ENVIRONMENT === "production", + }); + setCookie(c, "google_oauth_code_verifier", codeVerifier, { + httpOnly: true, + maxAge: 60 * 10, + path: "/", + secure: env(c).ENVIRONMENT === "production", + }); + return c.redirect(url.toString()); + } + + return c.json({}, 400); + } + ) + .all( + "/:provider/callback", + zValidator( + "param", + z.object({ provider: z.enum(["github", "google", "apple"]) }) + ), + async (c) => { + try { + const provider = c.req.valid("param").provider; + let stateCookie = getCookie(c, `${provider}_oauth_state`); + // console.log("stateCookie", stateCookie); + const codeVerifierCookie = getCookie( + c, + `${provider}_oauth_code_verifier` + ); + // console.log("codeVerifierCookie", codeVerifierCookie); + + const sessionTokenCookie = getCookie(c, "sessionToken"); + let redirect = getCookie(c, "redirect"); + // console.log("redirect", redirect); + + const url = new URL(c.req.url); + let state = url.searchParams.get("state"); + let code = url.searchParams.get("code"); + const codeVerifierRequired = ["google"].includes(provider); + if (c.req.method === "POST") { + const formData = await c.req.formData(); + state = formData.get("state") as string; + stateCookie = state ?? stateCookie; + code = formData.get("code") as string; + redirect = env(c).WEB_DOMAIN; + } + if ( + !state || + !stateCookie || + !code || + stateCookie !== state || + !redirect || + (codeVerifierRequired && !codeVerifierCookie) + ) { + return c.json({ error: "Invalid request" }, 400); + } + + if (provider === "google") { + const session = await createGoogleSession({ + c, + idToken: code, + codeVerifier: codeVerifierCookie ?? "", + sessionToken: sessionTokenCookie, + }); + if (!session) { + return c.json({}, 400); + } + setCookie(c, "session", session.id, { + httpOnly: true, + maxAge: 60 * 60 * 24 * 30, // 30 days + path: "/", + secure: env(c).ENVIRONMENT === "production", + }); + const redirectUrl = new URL(redirect); + return c.redirect(redirectUrl.toString()); + } + return c.json({}, 400); + } catch (error) { + console.error(error); + if (error instanceof Error) { + console.error(error.stack); + } + } + return c.json({ error: "Internal server error" }, 500); + } + ); + +export { AuthController }; diff --git a/src/controller/auth/google.ts b/src/controller/auth/google.ts new file mode 100644 index 0000000..6ca7ebe --- /dev/null +++ b/src/controller/auth/google.ts @@ -0,0 +1,205 @@ +import { generateRandomString } from "@oslojs/crypto/random"; +import type { RandomReader } from "@oslojs/crypto/random"; +import { Google, decodeIdToken } from "arctic"; +import type { Context } from "hono"; +import { env } from "hono/adapter"; + +import { + createSession, + generateSessionToken, + setSessionTokenCookie, + validateSessionToken, +} from "../../auth/oslo-auth"; +import type { AppBindings } from "bindings"; +import { oauthAccountTable } from "../../db/oauth.accounts"; +import { userTable } from "../../db/user"; +import type { DatabaseUserAttributes } from "../../types"; +import { fetchRefreshToken } from "../user/user.controller"; + +const googleClient = (c: Context) => + new Google( + env(c).GOOGLE_CLIENT_ID, + env(c).GOOGLE_CLIENT_SECRET, + `${env(c).API_DOMAIN}/auth/google/callback` + ); + +const getGoogleAuthorizationUrl = async ({ + c, + state, + codeVerifier, +}: { + c: Context; + state: string; + codeVerifier: string; +}) => { + const google = googleClient(c); + const url = await google.createAuthorizationURL(state, codeVerifier, [ + "openid", + "profile", + "email", + ]); + url.searchParams.append("prompt", "consent"); + url.searchParams.append("access_type", "offline"); + return url.toString(); +}; + +interface GoogleUser { + sub: string; + name: string; + email: string; + email_verified: boolean; + picture: string; +} +const createGoogleSession = async ({ + c, + idToken, + codeVerifier, + sessionToken, +}: { + c: Context; + idToken: string; + codeVerifier: string; + sessionToken?: string; +}) => { + try { + const google = googleClient(c); + + const tokens = await google.validateAuthorizationCode( + idToken, + codeVerifier + ); + // console.log("here", { tokens }); + // const claims = decodeIdToken(tokens.idToken()); + // console.log("claims", { claims }); + + const response = await fetch( + "https://openidconnect.googleapis.com/v1/userinfo", + { + headers: { + Authorization: `Bearer ${tokens.accessToken()}`, + }, + } + ); + + // if (!response.ok) { + // throw new Error("Failed to fetch user info from Google"); + // } + const user: GoogleUser = (await response.json()) as GoogleUser; + // console.log("user", { user }); + const existingAccount = await c.get("db").query.oauthAccount.findFirst({ + where: (account, { eq }) => + eq(account.providerUserId, user.sub.toString()), + }); + let existingUser: DatabaseUserAttributes | null = null; + if (sessionToken) { + const { session, user } = await validateSessionToken(c, sessionToken); + if (user) { + existingUser = user as DatabaseUserAttributes; + } + } else { + const response = await c.get("db").query.user.findFirst({ + where: (u, { eq }) => eq(u.email, user.email), + }); + if (response) { + existingUser = response; + } + } + if ( + existingUser?.emailVerified && + user.email_verified && + !existingAccount + ) { + await c.get("db").insert(oauthAccountTable).values({ + providerUserId: user.sub, + provider: "google", + userId: existingUser.id, + }); + const token = generateSessionToken(); + const session = await createSession(c, token, existingUser.id); + setSessionTokenCookie(c, token, session.expiresAt); + return session; + } + + if (existingAccount) { + const token = generateSessionToken(); + const session = await createSession(c, token, existingAccount.userId); + setSessionTokenCookie(c, token, session.expiresAt); + return session; + } + + let username = user.name; + const existingUsername = await c.get("db").query.user.findFirst({ + where: (u, { eq }) => eq(u.username, username), + }); + if (existingUsername) { + username = `${username}-${generateRandomString( + random, + "abcdefghijklmnopqrstuvwxyz0123456789", + 5 + )}`; + } + + let role = "audience"; + c.env.ADMINS.split(",").forEach((admin_mail) => { + if (user.email === admin_mail) { + role = "admin"; + } + }); + + // todo: error with insertuser available fields, file:types.ts, user/userCreate.ts, auth/google.ts, db/user.ts + // issue that is also in create user + // the createuser schema only has username and email somehow and not the other fields + await c + .get("db") + .insert(userTable) + .values({ + username, + email: user.email, + emailVerified: user.email_verified, + profilePictureUrl: user.picture, + refreshToken: tokens.refreshToken() ?? "", + role, + }); + const newUser = await c.get("db").query.user.findFirst({ + where: (u, { eq }) => eq(u.email, user.email), + }); + if (!newUser) { + throw new Error("Failed to create user"); + } + await c.get("db").insert(oauthAccountTable).values({ + providerUserId: user.sub, + provider: "google", + userId: newUser.id, + }); + const token = generateSessionToken(); + const session = await createSession(c, token, newUser.id); + setSessionTokenCookie(c, token, session.expiresAt); + return session; + } catch (error) { + console.log("error: ", error); + return error; + } +}; + +const getAccessToken = async (c: Context) => { + const google = googleClient(c); + const refreshToken = await fetchRefreshToken(c, c.get("user")?.id ?? ""); + if (!refreshToken) { + throw new Error("No refresh token found"); + } + const tokens = await google.refreshAccessToken(refreshToken); + return tokens.accessToken; +}; + +const random: RandomReader = { + read(bytes: Uint8Array): void { + crypto.getRandomValues(bytes); + }, +}; + +export { + getGoogleAuthorizationUrl, + createGoogleSession, + getAccessToken, + googleClient, +}; diff --git a/src/controller/user/user.controller.ts b/src/controller/user/user.controller.ts new file mode 100644 index 0000000..26c6d81 --- /dev/null +++ b/src/controller/user/user.controller.ts @@ -0,0 +1,30 @@ +import { Context, Hono } from "hono"; + +import type { AppBindings } from "bindings"; + +const UserController = new Hono() + .get("/profile", (c) => { + const user = c.get("user"); + return c.json(user); + }) + .get("/oauth-accounts", async (c) => { + const oauthAccounts = await c.get("db").query.oauthAccount.findMany({ + where: (u, { eq }) => eq(u.userId, c.get("user")?.id ?? ""), + }); + return c.json({ + accounts: oauthAccounts.map((oa) => ({ + provider: oa.provider, + })), + }); + }); + +const fetchRefreshToken = async (c: Context, id: string) => { + return c + .get("db") + .query.user.findFirst({ + where: (u, { eq }) => eq(u.id, Number(id)), + }) + .then((user) => user?.refreshToken); +}; + +export { UserController, fetchRefreshToken }; diff --git a/src/controller/user/userCreate.ts b/src/controller/user/userCreate.ts index f3dafb5..eb0eb56 100644 --- a/src/controller/user/userCreate.ts +++ b/src/controller/user/userCreate.ts @@ -1,8 +1,9 @@ import { Bool, OpenAPIRoute } from "chanfana"; import { z } from "zod"; -import { AppBindings } from "bindings"; +import type { AppBindings } from "bindings"; import { Context } from "hono"; import { insertUserSchema, selectUserSchema, userTable } from "db/user"; +import type { InsertUser } from "db/user"; export class UserCreate extends OpenAPIRoute { schema = { @@ -46,6 +47,7 @@ export class UserCreate extends OpenAPIRoute { } console.log("User", user); + // todo: error with insertuser available fields, file:types.ts, user/userCreate.ts, auth/google.ts, db/user.ts await c.get("db").insert(userTable).values({ username: user.username, email: user.email, diff --git a/src/controller/user/userDelete.ts b/src/controller/user/userDelete.ts index a3603a1..ee2b474 100644 --- a/src/controller/user/userDelete.ts +++ b/src/controller/user/userDelete.ts @@ -34,40 +34,40 @@ export class UserDelete extends OpenAPIRoute { }; async handle(c: Context) { - // try { - // //TODO:https://github.com/PoskOfficial/Miti/blob/v2/apps/api/src/auth/oslo-auth.ts read this thingy - // basicAuth({ - // username: c.env.USERNAME, - // password: c.env.PASSWORD, - // }); - // const data = await this.getValidatedData(); - // const userSlug = data.params.userSlug; - // const userRes = await c.get("db").query.user.findFirst({ - // where: (u, { eq }) => eq(u.id, userSlug), - // }); - // if (!userRes) { - // throw new Error("User Doesn't exist"); - // } - // if (userRes.role != "audience") { - // throw new Error("you can only delete audience"); - // } - // const res = await c - // .get("db") - // .delete(userTable) - // .where(eq(user.id, userSlug)); - // if (!res.success) { - // throw new Error("Could not delete from db"); - // } - // return { user: userRes }; - // } catch (error) { - // console.log("Error: ", error); - // return c.json( - // { - // success: false, - // error: String(error), - // }, - // 500, - // ); - // } + try { + //TODO:https://github.com/PoskOfficial/Miti/blob/v2/apps/api/src/auth/oslo-auth.ts read this thingy + basicAuth({ + username: c.env.USERNAME, + password: c.env.PASSWORD, + }); + const data = await this.getValidatedData(); + const userSlug = data.params.userSlug; + const userRes = await c.get("db").query.user.findFirst({ + where: (u, { eq }) => eq(u.id, userSlug), + }); + if (!userRes) { + throw new Error("User Doesn't exist"); + } + if (userRes.role != "audience") { + throw new Error("you can only delete audience"); + } + const res = await c + .get("db") + .delete(userTable) + .where(eq(user.id, userSlug)); + if (!res.success) { + throw new Error("Could not delete from db"); + } + return { user: userRes }; + } catch (error) { + console.log("Error: ", error); + return c.json( + { + success: false, + error: String(error), + }, + 500 + ); + } } } diff --git a/src/db/oauth.accounts.ts b/src/db/oauth.accounts.ts new file mode 100644 index 0000000..2631add --- /dev/null +++ b/src/db/oauth.accounts.ts @@ -0,0 +1,22 @@ +import { + primaryKey, + sqliteTable, + text, + integer, +} from "drizzle-orm/sqlite-core"; + +import { userTable } from "./user"; + +export const oauthAccountTable = sqliteTable( + "oauth_account", + { + provider: text("provider").notNull(), + providerUserId: text("provider_user_id").notNull().unique(), + userId: integer("user_id", { mode: "number" }) + .notNull() + .references(() => userTable.id), + }, + (table) => ({ + pk: primaryKey({ columns: [table.provider, table.providerUserId] }), + }) +); diff --git a/src/db/schema.ts b/src/db/schema.ts index 4e05fd7..ffc790e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,3 +1,9 @@ import { userTable } from "./user"; +import { oauthAccountTable } from "./oauth.accounts"; +import { sessionTable } from "./sessions"; -export { userTable as user }; +export { + userTable as user, + oauthAccountTable as oauthAccount, + sessionTable as session, +}; diff --git a/src/db/sessions.ts b/src/db/sessions.ts new file mode 100644 index 0000000..585d111 --- /dev/null +++ b/src/db/sessions.ts @@ -0,0 +1,16 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; + +import { userTable } from "./user"; + +const sessionTable = sqliteTable("session", { + id: text("id").notNull().primaryKey(), + userId: integer("user_id", { mode: "number" }) + .notNull() + .references(() => userTable.id), + expiresAt: integer("expires_at").notNull(), +}); + +// const selectSessionSchema = createSelectSchema(sessionTable); + +export { sessionTable }; diff --git a/src/db/user.ts b/src/db/user.ts index 1c272bd..7bf301a 100644 --- a/src/db/user.ts +++ b/src/db/user.ts @@ -1,7 +1,6 @@ import { sql } from "drizzle-orm"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; -import { z } from "zod"; const userTable = sqliteTable("user", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), @@ -21,17 +20,24 @@ const userTable = sqliteTable("user", { .notNull(), }); +type SelectUser = typeof userTable.$inferSelect; +// todo: error with insertuser available fields, file:types.ts, user/userCreate.ts, auth/google.ts, db/user.ts +// why does insertUser has only username and email fields +type InsertUser = typeof userTable.$inferInsert; + // Schema for inserting a user - can be used to validate API requests -const insertUserSchema = createInsertSchema(userTable, { - id: (schema) => schema.id.nullish(), - emailVerified: z.boolean(), - username: z.string(), -}); +const insertUserSchema = createInsertSchema(userTable); // Schema for selecting a user - can be used to validate API responses const selectUserSchema = createSelectSchema(userTable); -export { userTable, insertUserSchema, selectUserSchema }; +export { + userTable, + insertUserSchema, + selectUserSchema, + SelectUser, + InsertUser, +}; // Overriding the fields // const insertUserSchema = createInsertSchema(userTable, { diff --git a/src/index.ts b/src/index.ts index 98cef22..6cd93e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { admin, home } from "./routes/routes"; import { fromHono } from "chanfana"; import { UserCreate } from "controller/user/userCreate"; import { UserList } from "controller/user/userList"; +import { AuthController } from "controller/auth/auth.controller"; + const app = new Hono(); app .use(logger()) @@ -32,16 +34,16 @@ app app.route("/", home); app.route("/admin", admin); +app.route("/auth", AuthController); const openapi = fromHono(app, { docs_url: "/api", }); -// openapi.get("/user", UserList); openapi.post("/user", UserCreate); -openapi.get("/user", UserList); +// openapi.get("/user", UserList); +// openapi.delete("/user/:taskSlug", UserDelete); // openapi.get("/user/:taskSlug", UserRead); -// openapi.delete("/user/:taskSlug", UserDelete); export default app; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 79faa8f..9e59f58 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,6 +1,14 @@ import { AppBindings } from "bindings"; import { Hono } from "hono"; import { basicAuth } from "hono/basic-auth"; +import { html } from "hono/html"; +import { + getCookie, + getSignedCookie, + setCookie, + setSignedCookie, + deleteCookie, +} from "hono/cookie"; // admin route const admin = new Hono(); @@ -17,9 +25,22 @@ admin.get("/", (c) => { const home = new Hono(); home.get("/", (c) => { + const session = getCookie(c, "session"); + let loggedIn = false; + if (session) { + loggedIn = true; + } + return c.html(` - - + + ${Header()} + + ${Content(loggedIn)} + + + `); +}); +const Header = () => html` @@ -30,22 +51,42 @@ home.get("/", (c) => { name="description" content="BOSC is a club for opensource enthusiasts from Birendra Multiple Campus. This is the BOSC's official website's API." /> - - -

Welcome to BOSC API

-

- This is the API for the BOSC website. You can use this API to interact - with the BOSC website. -

- API -

Login as a Club Executive

-
- -
- - - `); -}); +`; + +const Content = (loggedIn: boolean) => html` +
+

Welcome to BOSC API

+

+ This is the API for the BOSC website. You can use this API to interact + with the BOSC website. +

+ API +

Login as a Club Executive

+ + ${loggedIn ? LogoutContent() : LoginContent()} +
+`; + +const LoginContent = () => html` +
+ + Sign in with Google + +
+`; +const LogoutContent = () => html` +
+ +
+`; export { admin, home }; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0bb64b6 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,20 @@ +import type { InferInsertModel } from "drizzle-orm"; + +import type { SelectUser, InsertUser } from "./db/user"; + +// todo: error with insertuser available fields, file:types.ts, user/userCreate.ts, auth/google.ts, db/user.ts +// when using insertuser, the available fields are only username and email +// but the user table has more fields +// when that is fixed, replace the SelectUser with InsertUser +export type DatabaseUserAttributes = SelectUser; + +export interface Session { + id: string; + userId: number; + expiresAt: number; +} + +export interface SessionValidationResult { + session: Session | null; + user: (DatabaseUserAttributes & { id: string }) | null; +}