feat!: replace auth with OIDC
Remove better-auth and replace it with OIDC
This commit is contained in:
@@ -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;
|
||||
|
||||
61
src/lib/components/lnv/sidebar/UserSidebar.svelte
Normal file
61
src/lib/components/lnv/sidebar/UserSidebar.svelte
Normal 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}
|
||||
@@ -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
79
src/lib/services/oidc.ts
Normal 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]));
|
||||
}
|
||||
12
src/main.ts
12
src/main.ts
@@ -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')!,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user