Working User Authorization Flow

This commit is contained in:
2026-01-26 15:56:30 -06:00
parent e9a7ded305
commit e517a45c0f
13 changed files with 349 additions and 92 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
@import 'tailwindcss';
@import "tailwindcss";
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
export let loading: boolean;
</script>
{#if loading}
<div class="overlay" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
</div>
{/if}
<style>
/* Overlay + spinner */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: auto;
}
.spinner {
width: 56px;
height: 56px;
border-radius: 50%;
border: 6px solid rgba(255, 255, 255, 0.18);
border-top-color: white;
animation: spin 0.9s linear infinite;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+51 -7
View File
@@ -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;
};
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);
}
};
+27
View File
@@ -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}`,
);
}
}
+1 -1
View File
@@ -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";
+42 -33
View File
@@ -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;
+57 -48
View File
@@ -1,53 +1,62 @@
<script>
function handleClick() {
const url = 'https://login.microsoftonline.com/89e22de3-9f59-49a4-9399-00265468a818/oauth2/v2.0/authorize?client_id=1e233eae-f7b6-49cf-80ab-2cf5c86cab66&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fv1%2Fauth%2Fredirect&scope=openid+User.Read';
const width = 420;
const height = 800;
const left = Math.round((window.screen.width - width) / 2);
const top = Math.round((window.screen.height - height) / 2);
const features = `width=${width},height=${height},left=${left},top=${top},resizable=no,menubar=no,toolbar=no,location=no,status=no,scrollbars=yes`;
window.open(url, 'msAuth', features);
import { goto } from '$app/navigation';
function signOut() {
// replace with your auth sign-out logic
goto('/logout');
}
</script>
<style>
:global(html, body, #svelte) {
height: 100%;
margin: 0;
}
.container {
min-height: 100vh;
min-width: 100vw;
display: flex;
align-items: center;
justify-content: center;
background: #f3f2f1;
}
.ms-button {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #2f2f2f;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
.ms-logo {
width: 20px;
height: 20px;
background: linear-gradient(90deg,#f25022 0 25%,#7fb900 25% 50%,#00a4ef 50% 75%,#ffb900 75% 100%);
border-radius: 2px;
flex-shrink: 0;
}
</style>
<svelte:head>
<title>Home — App</title>
</svelte:head>
<div class="container">
<button class="ms-button" on:click={handleClick} aria-label="Sign in with Microsoft">
<span class="ms-logo" aria-hidden="true"></span>
Sign in with Microsoft
</button>
</div>
<header class="header container">
<h1>App Home</h1>
<nav>
<a href="/projects">Projects</a>
<a href="/settings">Settings</a>
<a href="/profile">Profile</a>
<button on:click={signOut}>Sign out</button>
</nav>
</header>
<main class="container">
<section class="hero">
<h2>Welcome back</h2>
<p>This is your protected home page. Quick links and recent activity appear below.</p>
</section>
<section class="grid">
<article class="card">
<h3>Quick actions</h3>
<ul>
<li><a href="/projects/new">Create project</a></li>
<li><a href="/profile/edit">Edit profile</a></li>
</ul>
</article>
<article class="card">
<h3>Recent activity</h3>
<p>No recent activity.</p>
</article>
</section>
</main>
<footer class="container">
<small>© {new Date().getFullYear()} Your App</small>
</footer>
<style>
:global(body) { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; background:#f7f7f8; color:#111; }
.container { max-width: 960px; margin: 0 auto; padding: 1rem; }
header { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #e5e7eb; background: #fff; }
nav { display:flex; gap: .5rem; align-items:center; }
nav a { color:#0366d6; text-decoration:none; padding:.25rem .5rem; }
nav button { background:#ef4444; color:#fff; border:0; padding:.4rem .6rem; border-radius:6px; cursor:pointer; }
main { padding: 1.5rem 0; }
.hero h2 { margin:0 0 .5rem 0; }
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin-top:1rem; }
.card { background:#fff; border:1px solid #e5e7eb; padding:1rem; border-radius:8px; }
footer { text-align:center; padding:1rem 0; color:#6b7280; }
</style>
+24
View File
@@ -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;
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import { fetchAuthRedirectUri } from "$lib/authUri";
import { PUBLIC_API_URL } from "$env/static/public";
import { enhance } from "$app/forms";
import { goto } from "$app/navigation";
import LoadingSpinner from "../../components/LoadingSpinner.svelte";
import { writable } from "svelte/store";
const uriData = await fetchAuthRedirectUri(PUBLIC_API_URL);
let loading = writable(false);
function handleSubmit(e: SubmitEvent) {
if ($loading) return;
$loading = true;
const url = uriData.uri;
const width = 420;
const height = 430;
const left = Math.round((window.screen.width - width) / 2);
const top = Math.round((window.screen.height - height) / 2);
const features = `width=${width},height=${height},left=${left},top=${top},resizable=no,menubar=no,toolbar=no,location=no,status=no,scrollbars=yes`;
const popup = window.open(url, "msAuth", features);
if (popup) {
const popupCheckInterval = setInterval(() => {
console.log(popup);
if (popup.closed) {
clearInterval(popupCheckInterval);
$loading = false;
}
}, 500);
}
}
</script>
<div class="container">
<form action="?/login" method="POST" on:submit={handleSubmit} use:enhance>
<input type="hidden" name="callbackKey" value={uriData.callbackKey} />
<button
type="submit"
class="ms-button"
aria-label="Sign in with Microsoft"
disabled={$loading}
aria-busy={$loading}
>
<span class="ms-logo" aria-hidden="true"></span>
Sign in with Microsoft
</button>
</form>
</div>
<LoadingSpinner loading={$loading} />
<style>
:global(html, body, #svelte) {
height: 100%;
margin: 0;
}
.container {
min-height: 100vh;
min-width: 100vw;
display: flex;
align-items: center;
justify-content: center;
background: #f3f2f1;
}
.ms-button {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #2f2f2f;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.ms-button[disabled] {
opacity: 0.6;
cursor: default;
}
.ms-logo {
width: 20px;
height: 20px;
background: linear-gradient(
90deg,
#f25022 0 25%,
#7fb900 25% 50%,
#00a4ef 50% 75%,
#ffb900 75% 100%
);
border-radius: 2px;
flex-shrink: 0;
}
</style>