feat!: replace auth with OIDC

Remove better-auth and replace it with OIDC
This commit is contained in:
2025-06-22 11:41:28 +02:00
parent 02c019ba93
commit 5e186397dd
7 changed files with 176 additions and 69 deletions

View File

@@ -12,6 +12,8 @@
import Button from "../ui/button/button.svelte";
import { search, type Feature } from "$lib/services/Search";
import SearchSidebar from "./sidebar/SearchSidebar.svelte";
import RequiresCapability from "./RequiresCapability.svelte";
import UserSidebar from "./sidebar/UserSidebar.svelte";
import { advertiseRemoteLocation, location, remoteLocation } from "./location.svelte";
import * as Popover from "../ui/popover";
import { routing } from "$lib/services/navigation/routing.svelte";
@@ -22,7 +24,8 @@
info: InfoSidebar,
route: RouteSidebar,
trip: TripSidebar,
search: SearchSidebar
search: SearchSidebar,
user: UserSidebar,
};
let isDragging = false;

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { onMount } from "svelte";
import SidebarHeader from "./SidebarHeader.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { getAuthURL, getOIDCUser } from "$lib/services/oidc";
import * as Avatar from "$lib/components/ui/avatar";
let user: any = $state(null);
onMount(() => {
if(!localStorage.getItem("lnv-token")) {
user = null;
} else {
user = JSON.parse(atob((localStorage.getItem("lnv-id") || "").split(".")[1]));
}
})
</script>
{#if !user}
<SidebarHeader>
User
</SidebarHeader>
<Button onclick={async () => {
const auth = await getAuthURL();
// localStorage.setItem("lnv-codeVerifier", auth.codeVerifier);
// localStorage.setItem("lnv-oidcstate", auth.state);
const popup = window.open(auth.url, "Login", "width=500,height=600");
window.addEventListener("message", async (e) => {
if(e.origin !== window.location.origin) return;
const { code, state } = e.data;
console.log("Received data from popup:", e.data);
if(!code || !state) {
console.error("Invalid response from popup");
return;
}
popup?.close();
if(state !== auth.state) {
alert("State mismatch. Please try again.");
return;
}
const token = await getOIDCUser(code, auth.codeVerifier);
localStorage.setItem("lnv-id", token.id_token);
localStorage.setItem("lnv-token", token.access_token);
localStorage.setItem("lnv-refresh", token.refresh_token);
user = JSON.parse(atob((localStorage.getItem("lnv-id") || "").split(".")[1]));
})
}}>Login</Button>
{:else}
<SidebarHeader>
<Avatar.Root>
<Avatar.Image src={user.picture} />
<Avatar.Fallback>{user.preferred_username}</Avatar.Fallback>
</Avatar.Root>
{user.name || user.preferred_username}
</SidebarHeader>
<pre>{user.sub}</pre>
{JSON.stringify(user, null, 2)}
{/if}

View File

@@ -1,17 +1,8 @@
import { createAuthClient } from "better-auth/client";
import { usernameClient } from "better-auth/client/plugins";
import { LNV_SERVER } from "./hosts";
import type { User } from "better-auth";
export const authClient = createAuthClient({
baseURL: LNV_SERVER + "/auth",
plugins: [
usernameClient()
]
})
export type Capabilities = ("auth" | "reviews" | "ai" | "fuel")[];
export let capabilities: Capabilities = [];
export let oidcConfig: { AUTH_URL: string; CLIENT_ID: string; TOKEN_URL: string } | null = null;
export async function fetchConfig() {
const res = await fetch(LNV_SERVER + "/config");
@@ -19,7 +10,7 @@ export async function fetchConfig() {
throw new Error(`Failed to fetch capabilities: ${res.statusText}`);
}
const data = await res.json();
return data as { name: string; version: string; capabilities: Capabilities };
return data as { name: string; version: string; capabilities: Capabilities; oidc?: { AUTH_URL: string; CLIENT_ID: string; TOKEN_URL: string } };
}
export async function getCapabilities() {
@@ -30,6 +21,21 @@ export async function getCapabilities() {
return capabilities;
}
export async function getOIDCConfig() {
if (oidcConfig) {
return oidcConfig;
}
const config = await fetchConfig();
if (config.oidc) {
oidcConfig = {
AUTH_URL: config.oidc.AUTH_URL,
CLIENT_ID: config.oidc.CLIENT_ID,
TOKEN_URL: config.oidc.TOKEN_URL
};
}
return oidcConfig;
}
export async function hasCapability(capability: Capabilities[number]): Promise<boolean> {
const caps = await getCapabilities();
return caps.includes(capability);
@@ -57,15 +63,15 @@ export async function postReview(location: WorldLocation, review: Omit<Review, '
if(!await hasCapability("reviews")) {
throw new Error("Reviews capability is not available");
}
const session = await authClient.getSession();
if (session.error) {
const token = localStorage.getItem("lnv-token");
if (!token) {
throw new Error("User is not authenticated");
}
const res = await fetch(LNV_SERVER + `/review`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${session.data?.session.token}`
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
...review,

79
src/lib/services/oidc.ts Normal file
View File

@@ -0,0 +1,79 @@
// OIDC Server needs to have redirect url for /login/callback
import { getOIDCConfig } from "./lnv";
const oidcConfig = await getOIDCConfig();
if (!oidcConfig) {
throw new Error("Server does not support OIDC authentication");
}
const { AUTH_URL, CLIENT_ID, TOKEN_URL } = oidcConfig;
export { CLIENT_ID, TOKEN_URL };
export async function getAuthURL() {
const pkce = await generatePKCEChallenge();
const state = generateRandomString(16);
return {
url:
`${AUTH_URL}?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${window.location.origin}/login/callback&scope=openid%20profile&code_challenge=${pkce.codeChallenge}&code_challenge_method=S256&state=${state}`,
codeVerifier: pkce.codeVerifier,
state,
};
}
// Function to generate PKCE code challenge
// With the S256 method
async function generatePKCEChallenge() {
const codeVerifier = generateRandomString(128);
const codeChallengeBuf = await sha256(codeVerifier);
const codeChallenge = base64URLEncode(new Uint8Array(codeChallengeBuf));
return { codeVerifier, codeChallenge };
}
// Generates a cryptographically secure random string
function generateRandomString(length: number) {
const array = new Uint32Array(length / 2);
window.crypto.getRandomValues(array);
return Array.from(array, (dec) => ("0" + dec.toString(16)).substr(-2)).join("");
}
// Encodes a string to base64url (no padding)
function base64URLEncode(buffer: Uint8Array) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
async function sha256(input: string | undefined): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
return await window.crypto.subtle.digest("SHA-256", data);
}
export async function getOIDCUser(code: string, codeVerifier: string) {
const params = new URLSearchParams();
params.append("grant_type", "authorization_code");
params.append("code", code);
params.append("client_id", CLIENT_ID);
params.append("code_verifier", codeVerifier);
params.append("redirect_uri", window.location.origin + "/login/callback");
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params
}).then(res => res.json());
return res;
// return JSON.parse(atob(id_token.split(".")[1]));
}

View File

@@ -2,6 +2,18 @@ import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
if(location.href.includes("/login/callback")) {
const url = new URL(location.href);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if(code && state) {
window.opener.postMessage({
code, state
}, window.location.origin);
window.close();
}
}
const app = mount(App, {
target: document.getElementById('app')!,
})