From e517a45c0f4c44ab8c361dfd71fb61bba68764ef Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Mon, 26 Jan 2026 15:56:30 -0600 Subject: [PATCH] Working User Authorization Flow --- bun.lock | 3 + electron/main.ts | 2 +- package.json | 1 + src/app.css | 2 +- src/components/LoadingSpinner.svelte | 37 ++++++++++ src/hooks.server.ts | 58 +++++++++++++-- src/lib/authUri.ts | 27 +++++++ src/lib/index.ts | 2 +- src/lib/user.ts | 75 ++++++++++--------- src/routes/+page.svelte | 105 +++++++++++++++------------ src/routes/login/+page.server.ts | 24 ++++++ src/routes/login/+page.svelte | 98 +++++++++++++++++++++++++ svelte.config.js | 7 +- 13 files changed, 349 insertions(+), 92 deletions(-) create mode 100644 src/components/LoadingSpinner.svelte create mode 100644 src/lib/authUri.ts create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte diff --git a/bun.lock b/bun.lock index 286aefe..2d8c9e0 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "electron-svelte", "dependencies": { "axios": "^1.13.3", + "dotenv": "^17.2.3", "electron-squirrel-startup": "^1.0.1", "socket.io-client": "^4.8.3", }, @@ -605,6 +606,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "ds-store": ["ds-store@0.1.6", "", { "dependencies": { "bplist-creator": "0.0.8", "macos-alias": "0.2.12", "tn1150": "0.1.0" } }, "sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], diff --git a/electron/main.ts b/electron/main.ts index 6b0dc68..0c13cf6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -19,7 +19,7 @@ const createWindow = () => { // and load the index.html of the app. if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); + mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/login`); mainWindow.webContents.on("did-frame-finish-load", () => { mainWindow.webContents.openDevTools({ mode: "detach" }); }); diff --git a/package.json b/package.json index 77659ee..2cd35c6 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "axios": "^1.13.3", + "dotenv": "^17.2.3", "electron-squirrel-startup": "^1.0.1", "socket.io-client": "^4.8.3" }, diff --git a/src/app.css b/src/app.css index cd67023..4c62860 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,3 @@ -@import 'tailwindcss'; +@import "tailwindcss"; @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; diff --git a/src/components/LoadingSpinner.svelte b/src/components/LoadingSpinner.svelte new file mode 100644 index 0000000..a783104 --- /dev/null +++ b/src/components/LoadingSpinner.svelte @@ -0,0 +1,37 @@ + + +{#if loading} +
+ +
+{/if} + + diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b88efb0..41013b9 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,10 +1,54 @@ -import type { Handle } from '@sveltejs/kit'; +import { user } from "$lib"; +import { redirect, type Handle } from "@sveltejs/kit"; +import { access } from "fs"; +import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js"; export const handle: Handle = async ({ event, resolve }) => { - if (event.url.pathname.startsWith('/custom')) { - return new Response('custom response'); - } + const accessToken = event.cookies.get("access_token"); + const refreshToken = event.cookies.get("refresh_token"); - const response = await resolve(event); - return response; -}; \ No newline at end of file + if (event.url.pathname === "/logout") { + event.cookies.delete("access_token", { path: "/" }); + event.cookies.delete("refresh_token", { path: "/" }); + + redirect(303, "/login"); + + return resolve(event); + } + + if (event.url.pathname.startsWith("/login") && user.isLoggedIn()) { + return redirect(303, "/"); + } + + if (event.url.pathname.startsWith("/login")) { + return await resolve(event); + } + + if (!accessToken || !refreshToken) { + user.logout(event); + return resolve(event); + } + + try { + if (accessToken && refreshToken) { + const newSession = await user.refreshSession(refreshToken); + + console.log(newSession); + + event.cookies.set("access_token", newSession.accessToken, { + httpOnly: true, + path: "/", + }); + event.cookies.set("refresh_token", newSession.refreshToken, { + httpOnly: true, + path: "/", + }); + } + } catch (err) { + console.trace(err); + + user.logout(event); + } finally { + return await resolve(event); + } +}; diff --git a/src/lib/authUri.ts b/src/lib/authUri.ts new file mode 100644 index 0000000..c4018d1 --- /dev/null +++ b/src/lib/authUri.ts @@ -0,0 +1,27 @@ +import axios, { AxiosInstance } from "axios"; + +export async function fetchAuthRedirectUri(api_url: string): Promise<{ + uri: string; + callbackKey: string; +}> { + const client: AxiosInstance = axios.create({ + baseURL: api_url || "", + timeout: 5000, + }); + try { + const res = await client.get("/v1/auth/uri"); + const d = res.data ?? {}; + const uri = d.data.uri; + const callbackKey = d.data.callbackKey; + if (typeof uri !== "string" || !uri) + throw new Error("redirect uri missing from response"); + return { + uri, + callbackKey, + }; + } catch (e) { + throw new Error( + `Failed to fetch auth redirect uri: ${(e as Error).message}`, + ); + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 600e1ce..a352ed0 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,3 +1,3 @@ // place files you want to import through the `$lib` alias in this folder. -export * from "./axios"; +//export * from "./axios"; export * from "./user"; diff --git a/src/lib/user.ts b/src/lib/user.ts index 8f83421..2b09cfb 100644 --- a/src/lib/user.ts +++ b/src/lib/user.ts @@ -1,5 +1,7 @@ import { getRequestEvent } from "$app/server"; -import { redirect } from "@sveltejs/kit"; +import { PUBLIC_API_URL } from "$env/static/public"; +import { redirect, RequestEvent } from "@sveltejs/kit"; +import axios from "axios"; import { io } from "socket.io-client"; export const user = { @@ -9,39 +11,51 @@ export const user = { return !!authToken; }, - logout() { - const event = getRequestEvent(); + async refreshSession(refreshToken: string) { + const refreshedTokens = ( + await axios.post( + `${PUBLIC_API_URL}/v1/auth/refresh`, + {}, + { + headers: { + "x-refresh-token": refreshToken, + }, + }, + ) + ).data.data; + + console.log("Refreshed tokens:", refreshedTokens); + return refreshedTokens; + }, + + logout(event: RequestEvent) { if (!event) return; // Clear authentication cookies event.cookies.delete("authToken", { path: "/" }); event.cookies.delete("refreshToken", { path: "/" }); - return redirect(303, "/"); + return redirect(303, "/login"); }, /** * @todo Get communication with server working and setup a key system so that the frontend can listen for a specific key from the backend so that nobody can poach off of login events. + * + * Note: This function no longer mutates SvelteKit request event/cookies asynchronously. + * It returns the tokens to the caller so the caller (within the same request lifecycle) + * can set cookies using the event object synchronously. */ - async awaitAuthCallback(): Promise<{ - authToken: string; + async awaitAuthCallback(callbackKey: string): Promise<{ + accessToken: string; refreshToken: string; }> { - const event = getRequestEvent(); - if (!event) return Promise.reject(new Error("No request event")); - - const state = - event.url.searchParams.get("state") ?? - event.cookies.get("authState") ?? - ""; - if (!state) - return Promise.reject(new Error("Missing state to correlate socket")); - - const base = process.env.API_URL || ""; + const base = PUBLIC_API_URL || ""; return new Promise((resolve, reject) => { let settled = false; - const socket = io(base, { auth: { state }, transports: ["websocket"] }); + const socket = io(`${base}/auth_callback`, { + transports: ["websocket"], + }); const timeout = setTimeout( () => { if (settled) return; @@ -56,25 +70,15 @@ export const user = { const handlePayload = (payload: any) => { try { - const { authToken, refreshToken } = payload ?? {}; - if (authToken && refreshToken) { + const { accessToken, refreshToken } = payload ?? {}; + if (accessToken && refreshToken) { if (settled) return; settled = true; - event.cookies.set("authToken", authToken, { - path: "/", - httpOnly: true, - sameSite: "lax", - }); - event.cookies.set("refreshToken", refreshToken, { - path: "/", - httpOnly: true, - sameSite: "lax", - }); clearTimeout(timeout); try { socket.disconnect(); } catch {} - resolve({ authToken, refreshToken }); + resolve({ accessToken, refreshToken }); } } catch { // ignore parse errors @@ -82,8 +86,13 @@ export const user = { }; socket.on("connect", () => {}); - socket.on("auth-callback", handlePayload); - socket.on("message", handlePayload); + // listen for a specific callback key if provided + if (callbackKey) { + socket.on(`auth:login:callback:${callbackKey}`, handlePayload); + } else { + socket.on("auth-callback", handlePayload); + } + socket.on("message", console.log); socket.on("connect_error", (err: any) => { if (settled) return; settled = true; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d5c36c0..1deb595 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,53 +1,62 @@ - + + Home — App + -
- -
+
+

App Home

+ +
+ +
+
+

Welcome back

+

This is your protected home page. Quick links and recent activity appear below.

+
+ +
+ + +
+

Recent activity

+

No recent activity.

+
+
+
+ + + + \ No newline at end of file diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..3eb0752 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,24 @@ +import { user } from "$lib"; +import { Actions, redirect } from "@sveltejs/kit"; + +export const actions: Actions = { + login: async (event) => { + const data = await event.request.formData(); + + const tokens = await user.awaitAuthCallback( + data.get("callbackKey") as string, + ); + + event.cookies.set("access_token", tokens.accessToken, { + httpOnly: true, + path: "/", + }); + event.cookies.set("refresh_token", tokens.refreshToken, { + httpOnly: true, + path: "/", + }); + + // Redirect to home page after successful login + redirect(303, "/"); + }, +} satisfies Actions; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..99da6e9 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,98 @@ + + +
+
+ + +
+
+ + + + diff --git a/svelte.config.js b/svelte.config.js index 49a43aa..4f1806e 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -3,12 +3,17 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; const config = { preprocess: vitePreprocess(), + compilerOptions: { + experimental: { + async: true, + }, + }, kit: { adapter: adapter({ pages: ".vite/renderer/main_window", }), router: { - type: "hash", + type: "pathname", }, }, };