From 26d466fac3e88dc3ee4324dd6f348584c981ea3d Mon Sep 17 00:00:00 2001 From: d3rpp Date: Fri, 23 Aug 2024 12:05:14 +1200 Subject: [PATCH] added github auth and accounts --- .env.example | 5 +- bun.lockb | Bin 175084 -> 191444 bytes drizzle/0001_premium_khan.sql | 13 + drizzle/meta/0001_snapshot.json | 307 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 4 + src/app.d.ts | 5 +- src/hooks.server.ts | 19 +- .../account/account-header-component.svelte | 43 ++- src/lib/components/headers/app-header.svelte | 14 +- .../components/headers/landing-header.svelte | 9 +- src/lib/components/main.svelte | 2 +- .../dropdown-menu/dropdown-menu-item.svelte | 6 + src/lib/components/ui/dropdown-menu/index.ts | 17 +- src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 24 ++ src/lib/drizzle.ts | 19 ++ src/lib/server/auth/adapter.ts | 27 ++ src/lib/server/auth/hook.ts | 33 ++ src/lib/server/auth/oauth_methods.ts | 10 + src/lib/{ => server}/trpc/context.ts | 0 src/lib/server/trpc/router/auth.ts | 133 ++++++-- src/lib/server/trpc/router/init.ts | 27 +- src/params/oauth.ts | 12 + src/routes/(app)/+layout.server.ts | 14 +- src/routes/(app)/app/+layout.server.ts | 6 + src/routes/(app)/app/+layout.svelte | 5 +- src/routes/(auth)/+layout.server.ts | 6 + src/routes/(auth)/+layout.svelte | 2 +- src/routes/(auth)/auth/login/+page.svelte | 94 +++++- src/routes/(auth)/auth/logout/+server.ts | 18 + src/routes/(auth)/auth/sign-up/+page.svelte | 67 +++- .../auth/sign-up/onboarding/+page.server.ts | 4 +- .../(auth)/oauth/[method=oauth]/+server.ts | 36 ++ .../oauth/[method=oauth]/callback/+server.ts | 190 +++++++++++ src/routes/(landing)/+layout.server.ts | 7 + src/routes/(landing)/+layout.svelte | 5 +- src/routes/+layout.server.ts | 7 + tailwind.config.js | 3 + 39 files changed, 1107 insertions(+), 100 deletions(-) create mode 100644 drizzle/0001_premium_khan.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/lib/components/ui/separator/index.ts create mode 100644 src/lib/components/ui/separator/separator.svelte create mode 100644 src/lib/server/auth/adapter.ts create mode 100644 src/lib/server/auth/hook.ts create mode 100644 src/lib/server/auth/oauth_methods.ts rename src/lib/{ => server}/trpc/context.ts (100%) create mode 100644 src/params/oauth.ts create mode 100644 src/routes/(app)/app/+layout.server.ts create mode 100644 src/routes/(auth)/+layout.server.ts create mode 100644 src/routes/(auth)/auth/logout/+server.ts create mode 100644 src/routes/(auth)/oauth/[method=oauth]/+server.ts create mode 100644 src/routes/(auth)/oauth/[method=oauth]/callback/+server.ts create mode 100644 src/routes/(landing)/+layout.server.ts create mode 100644 src/routes/+layout.server.ts diff --git a/.env.example b/.env.example index a56b22d..55ac2c0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ # Example ENV File, use this to create the .env file. -DATABASE_FILE=./database.db \ No newline at end of file +DATABASE_FILE=./database.db + +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" diff --git a/bun.lockb b/bun.lockb index e198b5386802253ca82df957b4305f3184670095..eb29c9d725aba9059ea8ec37bed9079ef89cfd59 100755 GIT binary patch delta 41935 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 diff --git a/drizzle/0001_premium_khan.sql b/drizzle/0001_premium_khan.sql new file mode 100644 index 0000000..760ec75 --- /dev/null +++ b/drizzle/0001_premium_khan.sql @@ -0,0 +1,13 @@ +CREATE TABLE `oauth_connections` ( + `type` text NOT NULL, + `oauth_identifier` text NOT NULL, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text NOT NULL, + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..6ee9d88 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,307 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "94aceb85-f443-4330-8d38-ec182650c696", + "prevId": "86be13c9-c3e4-4d58-b449-58f12e9dfda3", + "tables": { + "email_addresses": { + "name": "email_addresses", + "columns": { + "email_id": { + "name": "email_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text(26)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_verified": { + "name": "is_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "0" + } + }, + "indexes": { + "email_addresses_email_address_unique": { + "name": "email_addresses_email_address_unique", + "columns": ["email_address"], + "isUnique": true + } + }, + "foreignKeys": { + "email_addresses_user_id_users_id_fk": { + "name": "email_addresses_user_id_users_id_fk", + "tableFrom": "email_addresses", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_connections": { + "name": "oauth_connections", + "columns": { + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oauth_identifier": { + "name": "oauth_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_connections_user_id_users_id_fk": { + "name": "oauth_connections_user_id_users_id_fk", + "tableFrom": "oauth_connections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "passwords": { + "name": "passwords", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "passwords_user_id_users_id_fk": { + "name": "passwords_user_id_users_id_fk", + "tableFrom": "passwords", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public_assets": { + "name": "public_assets", + "columns": { + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob": { + "name": "blob", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "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": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_aliases": { + "name": "user_aliases", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_ref": { + "name": "user_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias_name": { + "name": "alias_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_aliases_user_ref_users_id_fk": { + "name": "user_aliases_user_ref_users_id_fk", + "tableFrom": "user_aliases", + "tableTo": "users", + "columnsFrom": ["user_ref"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": ["username"], + "isUnique": true + } + }, + "foreignKeys": { + "users_profile_picture_public_assets_file_name_fk": { + "name": "users_profile_picture_public_assets_file_name_fk", + "tableFrom": "users", + "tableTo": "public_assets", + "columnsFrom": ["profile_picture"], + "columnsTo": ["file_name"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index dd84a69..46742cf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1724016516054, "tag": "0000_hot_sage", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1724366636684, + "tag": "0001_premium_khan", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 0f8ff8c..ccf9fab 100644 --- a/package.json +++ b/package.json @@ -43,13 +43,16 @@ "svelte-preprocess" ], "dependencies": { + "@lucia-auth/adapter-drizzle": "^1.1.0", "@tanstack/svelte-query": "^5.51.21", "@tanstack/svelte-query-devtools": "^5.51.21", "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", + "arctic": "^1.9.2", "bits-ui": "^0.21.13", "clsx": "^2.1.1", "drizzle-orm": "^0.33.0", + "lucia": "^3.2.0", "lucide-svelte": "^0.428.0", "mode-watcher": "^0.4.1", "svelte-radix": "^1.1.0", @@ -57,6 +60,7 @@ "tailwind-variants": "^0.2.1", "trpc-svelte-query-adapter": "^2.3.14", "trpc-sveltekit": "^3.6.2", + "tsafe": "^1.7.2", "ulid": "^2.3.0", "zod": "^3.23.8" } diff --git a/src/app.d.ts b/src/app.d.ts index 743f07b..fc92ec8 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,7 +3,10 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user: import("lucia").User | null; + session: import("lucia").Session | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 97e86a1..ad8fbf1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,21 @@ -import { createContext } from "$lib/trpc/context"; +import { lucia } from "$lib/server/auth/adapter"; +import { authHook } from "$lib/server/auth/hook"; +import { createContext } from "$lib/server/trpc/context"; import { router } from "$lib/server/trpc/router"; + import type { Handle } from "@sveltejs/kit"; +import { sequence } from "@sveltejs/kit/hooks"; + import { createTRPCHandle } from "trpc-sveltekit"; -export const handle: Handle = createTRPCHandle({ router, createContext }); +const ONE_MINUTE_IN_MILLIS = 1000 * 60; + +setInterval(() => { + console.info("clearing sessions"); + lucia.deleteExpiredSessions(); +}, ONE_MINUTE_IN_MILLIS); + +export const handle: Handle = sequence( + authHook, + createTRPCHandle({ router, createContext }), +); diff --git a/src/lib/components/account/account-header-component.svelte b/src/lib/components/account/account-header-component.svelte index fad1ec3..cb42e03 100644 --- a/src/lib/components/account/account-header-component.svelte +++ b/src/lib/components/account/account-header-component.svelte @@ -3,25 +3,44 @@ import * as Avatar from "@/ui/avatar"; import * as DropdownMenu from "@/ui/dropdown-menu"; - import { goto } from "$app/navigation"; + + import type { User as UserType } from "lucia"; + + interface Props { + user: UserType | null; + } + + const { user = null }: Props = $props(); - + {#if user} + + {/if} - - goto("/auth/login")}> - Login - - goto("/auth/sign-up")}> - Sign Up - + + {#if user === null} + + Login + Sign Up + {:else} + + {user.display_name || user.username} + + + Dashboard + Account + + + Log Out + + {/if} diff --git a/src/lib/components/headers/app-header.svelte b/src/lib/components/headers/app-header.svelte index 3123b04..9717f46 100644 --- a/src/lib/components/headers/app-header.svelte +++ b/src/lib/components/headers/app-header.svelte @@ -1,16 +1,26 @@
-
+
+ {APPLICATION_NAME} @@ -19,7 +29,7 @@
diff --git a/src/lib/components/headers/landing-header.svelte b/src/lib/components/headers/landing-header.svelte index 877ba9f..a9e37ac 100644 --- a/src/lib/components/headers/landing-header.svelte +++ b/src/lib/components/headers/landing-header.svelte @@ -1,7 +1,14 @@ diff --git a/src/lib/components/main.svelte b/src/lib/components/main.svelte index a51e356..c22b420 100644 --- a/src/lib/components/main.svelte +++ b/src/lib/components/main.svelte @@ -15,7 +15,7 @@ usable and can be expanded later to also work on mobile. --> -
+
{#if children} {@render children()} {/if} diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte index b6f2345..766a840 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -7,6 +7,8 @@ const { class: className, inset = false, + href, + variant = "default", // Events onclick, @@ -28,8 +30,12 @@ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50", inset && "pl-8", className, + href !== undefined && "cursor-pointer", + variant === "destructive" && + "data-[highlighted]:bg-destructive-foreground", )} {...rest} + {href} {onclick} {onkeydown} {onfocusin} diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts index 2c21f6c..d572bc0 100644 --- a/src/lib/components/ui/dropdown-menu/index.ts +++ b/src/lib/components/ui/dropdown-menu/index.ts @@ -1,7 +1,4 @@ -import { - DropdownMenu as DropdownMenuPrimitive, - type MenubarPropsWithoutHTML, -} from "bits-ui"; +import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import type { HTMLAttributes } from "svelte/elements"; import type { Snippet } from "svelte"; @@ -23,15 +20,15 @@ const Group = DropdownMenuPrimitive.Group; type ChildrenProp = { children?: Snippet }; -export type CheckBoxItemProps = DropdownMenuPrimitive.CheckboxItemProps & - ChildrenProp; +export type CheckBoxItemProps = DropdownMenuPrimitive.CheckboxItemProps; export type ContentProps = DropdownMenuPrimitive.ContentProps & ChildrenProp; -export type ItemProps = Omit & - HTMLAttributes & { - inset?: boolean; - } & ChildrenProp; +export type ItemProps = HTMLAttributes & { + inset?: boolean; + href?: string; + variant?: "default" | "destructive"; +} & ChildrenProp; export type LabelProps = DropdownMenuPrimitive.LabelProps & { inset?: boolean; diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..e6723af --- /dev/null +++ b/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/lib/drizzle.ts b/src/lib/drizzle.ts index 7fa54c4..f775956 100644 --- a/src/lib/drizzle.ts +++ b/src/lib/drizzle.ts @@ -128,6 +128,25 @@ export const passwordTable = sqliteTable("passwords", { export type PasswordSelectModel = InferSelectModel; export type PasswordInsertModel = InferInsertModel; +export const sessionTable = sqliteTable("sessions", { + id: text("id", { mode: "text" }).notNull(), + userId: text("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: integer("expires_at").notNull(), +}); + +export type SessionSelectModel = InferSelectModel; +export type SessionInsertModel = InferInsertModel; + +export const oauthConnectionTable = sqliteTable("oauth_connections", { + type: text("type", { mode: "text" }).notNull().$type<"github">(), + oauth_identifier: text("oauth_identifier", { mode: "text" }).notNull(), + user_id: text("user_id", { mode: "text" }) + .notNull() + .references(() => userTable.id), +}); + /** * This is a table that allows us to do a quick and dirty user public assets api * diff --git a/src/lib/server/auth/adapter.ts b/src/lib/server/auth/adapter.ts new file mode 100644 index 0000000..db202d1 --- /dev/null +++ b/src/lib/server/auth/adapter.ts @@ -0,0 +1,27 @@ +import { Lucia } from "lucia"; +import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; + +import { DB } from "$lib/server/db"; +import { userTable, sessionTable, type UserSelectModel } from "$lib/drizzle"; + +import { dev } from "$app/environment"; + +const luciaAdapter = new DrizzleSQLiteAdapter(DB, sessionTable, userTable); + +export const lucia = new Lucia(luciaAdapter, { + sessionCookie: { + attributes: { + secure: !dev, + }, + }, + getUserAttributes: (db_user) => { + return { ...db_user }; + }, +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: Omit; + } +} diff --git a/src/lib/server/auth/hook.ts b/src/lib/server/auth/hook.ts new file mode 100644 index 0000000..eb8e747 --- /dev/null +++ b/src/lib/server/auth/hook.ts @@ -0,0 +1,33 @@ +import type { Handle } from "@sveltejs/kit"; + +import { lucia } from "$lib/server/auth/adapter"; + +export const authHook: Handle = async ({ event, resolve }) => { + const sessionId = event.cookies.get(lucia.sessionCookieName); + if (!sessionId) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { user, session } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes, + }); + } + + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes, + }); + } + + event.locals.user = user; + event.locals.session = session; + return resolve(event); +}; diff --git a/src/lib/server/auth/oauth_methods.ts b/src/lib/server/auth/oauth_methods.ts new file mode 100644 index 0000000..92b6be3 --- /dev/null +++ b/src/lib/server/auth/oauth_methods.ts @@ -0,0 +1,10 @@ +import { GitHub } from "arctic"; +import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from "$env/static/private"; + +const methods = { + github: new GitHub(GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET), +}; + +export type ValidOauthMethods = keyof typeof methods; + +export const github = methods.github; diff --git a/src/lib/trpc/context.ts b/src/lib/server/trpc/context.ts similarity index 100% rename from src/lib/trpc/context.ts rename to src/lib/server/trpc/context.ts diff --git a/src/lib/server/trpc/router/auth.ts b/src/lib/server/trpc/router/auth.ts index 336eec0..e491103 100644 --- a/src/lib/server/trpc/router/auth.ts +++ b/src/lib/server/trpc/router/auth.ts @@ -1,33 +1,35 @@ -import z from "zod"; -import { trpcInstance } from "./init"; -import { USERNAME_SCHEMA, PASSWORD_SCHEMA } from "$lib/trpc/schemas"; - +import { trpcInstance } from "$lib/server/trpc/router/init"; import { DB } from "$lib/server/db"; +import { lucia } from "$lib/server/auth/adapter"; +import { USERNAME_SCHEMA, PASSWORD_SCHEMA } from "$lib/trpc/schemas"; import { userTable, userAliasTable, passwordTable } from "$lib/drizzle"; -import { eq, or } from "drizzle-orm"; + +import z from "zod"; +import { count, eq } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; + +// #region Check Username Availability Implementation +const checkAvailability = async (username: string) => { + const [user_query, alias_query] = await Promise.all([ + DB.select({ users: count(userTable.username) }) + .from(userTable) + .where(eq(userTable.username, username)), + DB.select({ aliases: count(userAliasTable.alias_name) }) + .from(userAliasTable) + .where(eq(userAliasTable.alias_name, username)), + ]); + + return user_query[0].users + alias_query[0].aliases == 0; +}; +// #endregion export const authRouter = trpcInstance.router({ // #region Check Username check_username_availability: trpcInstance.procedure .input(USERNAME_SCHEMA) .query(async (opts) => { - const username = opts.input; - - const query = await DB.select({ - user_id: userTable.id, - user_alias_id: userAliasTable.id, - }) - .from(userTable) - .innerJoin(userAliasTable, eq(userTable.id, userAliasTable.id)) - .where( - or( - eq(userTable.username, username), - eq(userAliasTable.alias_name, username), - ), - ); - return { - available: query.length === 0, + available: await checkAvailability(opts.input), }; }), // #endregion @@ -44,6 +46,12 @@ export const authRouter = trpcInstance.router({ const password_hash = await Bun.password.hash(password, "argon2id"); + if (!(await checkAvailability(username))) + throw new TRPCError({ + code: "CONFLICT", + message: "Username is already taken", + }); + const user_id = await DB.transaction(async (tx) => { const user = await tx .insert(userTable) @@ -60,9 +68,88 @@ export const authRouter = trpcInstance.router({ return user[0].user_id; }); - console.log("Created User", user_id); + const session = await lucia.createSession(user_id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + opts.ctx.event.cookies.set( + sessionCookie.name, + sessionCookie.value, + { + path: ".", + ...sessionCookie.attributes, + }, + ); + + return; + }), + // #endregion + // #region Log In + log_in: trpcInstance.procedure + .input( + z.object({ + username: USERNAME_SCHEMA, + password: PASSWORD_SCHEMA, + }), + ) + .mutation(async (opts) => { + const { username, password } = opts.input; + + const existing_user_list = await DB.select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.username, username)) + .limit(1); + + if (existing_user_list.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Username or Password is invalid.", + }); + } + + const [existing_user] = existing_user_list; + + const password_list = await DB.select({ + password_hash: passwordTable.password_hash, + }) + .from(passwordTable) + .where(eq(passwordTable.user_id, existing_user.id)); + + if (password_list.length === 0) { + // user does exist but does not have a password + // this would mean the user used an oauth method. + throw new TRPCError({ + code: "UNAUTHORIZED", + message: + "Username does exist but does not have a password, you may have signed up with an OAuth2 Method.", + }); + } + + const [existing_password] = password_list; + + const compared_password = await Bun.password.verify( + password, + existing_password.password_hash, + "argon2id", + ); + + if (!compared_password) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Username or Password is invalid.", + }); + } else { + const session = await lucia.createSession(existing_user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + opts.ctx.event.cookies.set( + sessionCookie.name, + sessionCookie.value, + { + path: ".", + ...sessionCookie.attributes, + }, + ); - // todo: set user token cookie + return; + } }), // #endregion }); diff --git a/src/lib/server/trpc/router/init.ts b/src/lib/server/trpc/router/init.ts index 09c1175..8cf1aa1 100644 --- a/src/lib/server/trpc/router/init.ts +++ b/src/lib/server/trpc/router/init.ts @@ -1,6 +1,29 @@ -import { initTRPC } from "@trpc/server"; -import type { Context } from "$lib/trpc/context"; +import type { Context } from "$lib/server/trpc/context"; + import { ZodError } from "zod"; +import { initTRPC } from "@trpc/server"; + +// // @ts-expect-error I know the type, but it's really bloody long +// export const auth_middleware = (opts) => { +// const user = opts.ctx.event.locals.getAuth(); + +// if (!user) { +// throw new TRPCError({ +// code: "UNAUTHORIZED" +// }); +// } else if (user.error) { +// throw new TRPCError({ +// code: "UNAUTHORIZED", +// message: user.error +// }); +// } else { +// return opts.next({ +// ctx: { +// user: user as JwtPayload +// } +// }); +// } +// } export const trpcInstance = initTRPC.context().create({ errorFormatter: ({ shape, error }) => { diff --git a/src/params/oauth.ts b/src/params/oauth.ts new file mode 100644 index 0000000..e2026ab --- /dev/null +++ b/src/params/oauth.ts @@ -0,0 +1,12 @@ +import type { ValidOauthMethods } from "$lib/server/auth/oauth_methods"; +import { assert, type Equals } from "tsafe"; + +const valid_oauth_methods = ["github"] as const; + +type OAuth2MethodNames = (typeof valid_oauth_methods)[number]; + +assert>(); + +export const match = (param: OAuth2MethodNames) => { + return valid_oauth_methods.includes(param); +}; diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index dfac177..36fd0bb 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,7 +1,9 @@ -// import type { LayoutServerLoad } from "./$types"; +import { redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from "./$types"; -// export const load = (async (event) => { -// const user_cookie = event.cookies.get("session-token"); - -// return {}; -// }) satisfies LayoutServerLoad; +/** + * App shall require auth. + */ +export const load = (async (event) => { + if (event.locals.session === null) redirect(302, "/auth/login"); +}) satisfies LayoutServerLoad; diff --git a/src/routes/(app)/app/+layout.server.ts b/src/routes/(app)/app/+layout.server.ts new file mode 100644 index 0000000..ea95f25 --- /dev/null +++ b/src/routes/(app)/app/+layout.server.ts @@ -0,0 +1,6 @@ +import type { LayoutServerLoad } from "./$types"; + +export const load = (async (event) => { + const { user } = await event.parent(); + return { user }; +}) satisfies LayoutServerLoad; diff --git a/src/routes/(app)/app/+layout.svelte b/src/routes/(app)/app/+layout.svelte index 00d7c3a..fcb6240 100644 --- a/src/routes/(app)/app/+layout.svelte +++ b/src/routes/(app)/app/+layout.svelte @@ -1,10 +1,11 @@ - +
{@render children()} diff --git a/src/routes/(auth)/+layout.server.ts b/src/routes/(auth)/+layout.server.ts new file mode 100644 index 0000000..a176fe2 --- /dev/null +++ b/src/routes/(auth)/+layout.server.ts @@ -0,0 +1,6 @@ +import { redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from "./$types"; + +export const load = (async (event) => { + if (event.locals.session !== null) redirect(302, "/app"); +}) satisfies LayoutServerLoad; diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte index 68d5c37..91b2bc6 100644 --- a/src/routes/(auth)/+layout.svelte +++ b/src/routes/(auth)/+layout.svelte @@ -6,7 +6,7 @@
- + {@render children()} diff --git a/src/routes/(auth)/auth/login/+page.svelte b/src/routes/(auth)/auth/login/+page.svelte index 1a1599c..4c3d15d 100644 --- a/src/routes/(auth)/auth/login/+page.svelte +++ b/src/routes/(auth)/auth/login/+page.svelte @@ -1,19 +1,28 @@ @@ -65,6 +96,7 @@ { if ( @@ -87,6 +119,17 @@ />
+ + + {#if $log_in_mutation.isError} +
+ {$log_in_mutation.error.message} +
+ {/if} + - - {#await secret_key_json} - - {:then json} - {#if json} -
-
{JSON.stringify(json, null, 2)}
-
- {/if} - {/await} + +
+ + or + +
+ + + + {#await secret_key_json} + + {:then json} + {#if json} +
+
{JSON.stringify(json, null, 2)}
+
+ {/if} + {/await} diff --git a/src/routes/(auth)/auth/logout/+server.ts b/src/routes/(auth)/auth/logout/+server.ts new file mode 100644 index 0000000..6648188 --- /dev/null +++ b/src/routes/(auth)/auth/logout/+server.ts @@ -0,0 +1,18 @@ +import { lucia } from "$lib/server/auth/adapter"; +import type { RequestHandler } from "./$types"; + +export const GET = (async (event) => { + const session = event.locals.session; + if (session) await lucia.invalidateSession(session.id); + event.cookies.set(lucia.sessionCookieName, "", { + path: ".", + expires: new Date(0), + }); + + return new Response(null, { + status: 302, + headers: { + Location: "/", + }, + }); +}) satisfies RequestHandler; diff --git a/src/routes/(auth)/auth/sign-up/+page.svelte b/src/routes/(auth)/auth/sign-up/+page.svelte index eee28e9..c242eab 100644 --- a/src/routes/(auth)/auth/sign-up/+page.svelte +++ b/src/routes/(auth)/auth/sign-up/+page.svelte @@ -19,13 +19,16 @@ } from "svelte/store"; import { slide, fade } from "svelte/transition"; + import Main from "@/main.svelte"; + import { Button } from "@/ui/button"; import * as Card from "@/ui/card"; import { Input } from "@/ui/input"; import { Label } from "@/ui/label"; - import Main from "@/main.svelte"; + import { Separator } from "@/ui/separator"; import { CircleCheck, CircleX, LoaderCircle } from "lucide-svelte"; + import { GithubLogo } from "svelte-radix"; const rpc = trpc($page); // const utils = rpc.createUtils(); @@ -56,6 +59,7 @@ */ const username_invalid_error_text: Readable = derived_store(username_availability_query, (val) => { + console.log(val.data); if (val.data && val.data.available === false) return "Username is Taken"; @@ -100,6 +104,16 @@ $sign_up_mutation.mutate({ username: get(username), password }); }; + + /** + * Sign Up with Github Button Handler + */ + const github_signup_onclick = (ev: MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + goto("/oauth/github"); + };
@@ -114,6 +128,8 @@
diff --git a/src/routes/(auth)/auth/sign-up/onboarding/+page.server.ts b/src/routes/(auth)/auth/sign-up/onboarding/+page.server.ts index 03993db..b485e62 100644 --- a/src/routes/(auth)/auth/sign-up/onboarding/+page.server.ts +++ b/src/routes/(auth)/auth/sign-up/onboarding/+page.server.ts @@ -1,3 +1,3 @@ -import type { PageServerLoad } from "./$types"; +// import type { PageServerLoad } from "./$types"; -export const load = (async (ev) => {}) satisfies PageServerLoad; +// export const load = (async (ev) => {}) satisfies PageServerLoad; diff --git a/src/routes/(auth)/oauth/[method=oauth]/+server.ts b/src/routes/(auth)/oauth/[method=oauth]/+server.ts new file mode 100644 index 0000000..2bbd66f --- /dev/null +++ b/src/routes/(auth)/oauth/[method=oauth]/+server.ts @@ -0,0 +1,36 @@ +import type { RequestHandler, RequestEvent } from "./$types"; + +import { generateState } from "arctic"; +import { github, type ValidOauthMethods } from "$lib/server/auth/oauth_methods"; + +import { redirect } from "@sveltejs/kit"; +import { assert, type Equals } from "tsafe"; + +/** + * a key for each oauth2 method, returns the URL to redirect the user to + */ +const OAUTH_METHODS = { + github: async (event: RequestEvent) => { + const state = generateState(); + const url = await github.createAuthorizationURL(state); + + event.cookies.set("github_oauth_state", state, { + path: "/", + secure: import.meta.env.PROD, + httpOnly: true, + maxAge: 60 * 10, // 10 minutes + sameSite: "lax", + }); + + return url; + }, +}; + +assert>(); + +export const GET = (async (event) => { + const method_key = event.params.method as keyof typeof OAUTH_METHODS; + const redirect_url = await OAUTH_METHODS[method_key](event); + + redirect(302, redirect_url); +}) satisfies RequestHandler; diff --git a/src/routes/(auth)/oauth/[method=oauth]/callback/+server.ts b/src/routes/(auth)/oauth/[method=oauth]/callback/+server.ts new file mode 100644 index 0000000..f027b7b --- /dev/null +++ b/src/routes/(auth)/oauth/[method=oauth]/callback/+server.ts @@ -0,0 +1,190 @@ +import type { RequestHandler, RequestEvent } from "./$types"; + +import { OAuth2RequestError } from "arctic"; +import { assert, type Equals } from "tsafe"; +import { and, eq } from "drizzle-orm"; + +import { github, type ValidOauthMethods } from "$lib/server/auth/oauth_methods"; +import { lucia } from "$lib/server/auth/adapter"; + +import { DB } from "$lib/server/db"; +import { + userTable, + oauthConnectionTable, + publicAssetTable, +} from "$lib/drizzle"; + +/** + * Source: https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user + */ +interface GitHubUser { + /** + * OAuth2 ID + */ + id: number; + /** + * username + */ + login: string; + /** + * display_name + */ + name?: string; + /** + * URL for Avatar Image + */ + avatar_url: string; +} + +const OAUTH_CALLBACK_HANDLERS = { + github: async (event: RequestEvent) => { + const code = event.url.searchParams.get("code"); + const state = event.url.searchParams.get("state"); + const storedState = event.cookies.get("github_oauth_state"); + + if (!code || !state || !storedState || state !== storedState) { + console.error({ + code, + state, + storedState, + + state_is_eq: state === storedState, + }); + + return new Response(null, { + status: 400, + }); + } + + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch( + "https://api.github.com/user", + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + signal: event.request.signal, + }, + ); + const githubUser: GitHubUser = await githubUserResponse.json(); + + const existing_user = await DB.select({ + user_id: oauthConnectionTable.user_id, + }) + .from(oauthConnectionTable) + .where( + and( + eq(oauthConnectionTable.type, "github"), + eq( + oauthConnectionTable.oauth_identifier, + githubUser.id.toString(), + ), + ), + ); + if (existing_user.length === 0) { + // no user, create a new user + + // attempt to get the Avatar from GitHub + const avatar_fetch = await fetch(githubUser.avatar_url); + const image_type = avatar_fetch.headers.get("content-type")!; + const image_stream = new Uint8Array( + await avatar_fetch.arrayBuffer(), + ); + + const new_user_id = await DB.transaction(async (tx) => { + const [{ avatar_asset_id }] = await tx + .insert(publicAssetTable) + .values({ + type: image_type, + blob: image_stream, + }) + .returning({ avatar_asset_id: publicAssetTable.id }); + + const [{ new_user_id }] = await tx + .insert(userTable) + .values({ + username: githubUser.login, + display_name: githubUser.name, + profile_picture: avatar_asset_id, + }) + .returning({ new_user_id: userTable.id }); + + await tx.insert(oauthConnectionTable).values({ + type: "github", + user_id: new_user_id, + oauth_identifier: githubUser.id.toString(), + }); + + return new_user_id; + }); + + // new user created, now create session and log them in + const session = await lucia.createSession(new_user_id, {}); + const session_cookie = lucia.createSessionCookie(session.id); + event.cookies.set(session_cookie.name, session_cookie.value, { + path: ".", + ...session_cookie.attributes, + }); + // clear github oauth state + event.cookies.set("github_oauth_state", "", { + path: ".", + expires: new Date(), + }); + + return new Response(null, { + status: 302, + headers: { + Location: "/auth/sign-up/onboarding", + }, + }); + } else { + // user found, log them in + const [{ user_id }] = existing_user; + + const session = await lucia.createSession(user_id, {}); + const session_cookie = lucia.createSessionCookie(session.id); + event.cookies.set(session_cookie.name, session_cookie.value, { + path: ".", + ...session_cookie.attributes, + }); + // clear github oauth state + event.cookies.set("github_oauth_state", "", { + path: ".", + expires: new Date(0), + }); + + return new Response(null, { + status: 302, + headers: { + Location: "/app", + }, + }); + } + } catch (e) { + console.error(e); + + if (e instanceof OAuth2RequestError) { + return new Response(null, { + status: 400, + }); + } + + return new Response(null, { + status: 500, + }); + } + + return new Response(null, { + status: 500, + }); + }, +}; + +assert>(); + +export const GET = (async (event) => { + const oauth_method = event.params + .method as keyof typeof OAUTH_CALLBACK_HANDLERS; + return await OAUTH_CALLBACK_HANDLERS[oauth_method](event); +}) satisfies RequestHandler; diff --git a/src/routes/(landing)/+layout.server.ts b/src/routes/(landing)/+layout.server.ts new file mode 100644 index 0000000..70db7c7 --- /dev/null +++ b/src/routes/(landing)/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from "./$types"; + +export const load = (async (event) => { + const { user } = await event.parent(); + + return { user }; +}) satisfies LayoutServerLoad; diff --git a/src/routes/(landing)/+layout.svelte b/src/routes/(landing)/+layout.svelte index fbe1860..86344a5 100644 --- a/src/routes/(landing)/+layout.svelte +++ b/src/routes/(landing)/+layout.svelte @@ -3,10 +3,11 @@ import { LandingFooter } from "@/footers"; import Main from "@/main.svelte"; - const { children } = $props(); + const { data, children } = $props(); + const { user } = data; - +
{@render children()} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..3e1d5ce --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from "./$types"; + +export const load = (async (event) => { + return { + user: event.locals.user, + }; +}) satisfies LayoutServerLoad; diff --git a/tailwind.config.js b/tailwind.config.js index 1082fe3..4165e2a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,6 +14,9 @@ const config = { }, }, extend: { + transitionProperty: { + height: "height", + }, colors: { border: "hsl(var(--border) / )", input: "hsl(var(--input) / )",