Working User Authorization Flow
This commit is contained in:
@@ -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=="],
|
||||
|
||||
+1
-1
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@@ -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>
|
||||
+50
-6
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
+6
-1
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user