From 8cce3258a2ebe7568a25a68b05380c02e79c44c2 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Mon, 18 Mar 2024 13:06:45 -0400 Subject: [PATCH] feat: add login via oidc or api key --- .env.example | 7 +- package-lock.json | 11 ++- package.json | 3 +- src/app.d.ts | 5 +- src/hooks.server.ts | 12 +++ src/lib/api/apiKey.ts | 10 +++ src/lib/api/index.ts | 24 ++++- src/lib/crypto.ts | 87 +++++++++++++++++++ src/routes/(routes)/+layout.svelte | 25 ++++++ .../{ => (routes)}/machines/+page.svelte | 10 ++- src/routes/{ => (routes)}/machines/+page.ts | 8 +- .../{ => (routes)}/machines/[id]/+page.svelte | 1 + .../{ => (routes)}/machines/[id]/+page.ts | 9 +- src/routes/+layout.server.ts | 10 ++- src/routes/+layout.svelte | 24 +---- src/routes/+layout.ts | 8 +- src/routes/login/+page.svelte | 38 ++++++++ src/routes/oidc/callback/+server.ts | 84 ++++++++++++++++++ src/routes/oidc/start/+server.ts | 42 +++++++++ 19 files changed, 378 insertions(+), 40 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/api/apiKey.ts create mode 100644 src/lib/crypto.ts create mode 100644 src/routes/(routes)/+layout.svelte rename src/routes/{ => (routes)}/machines/+page.svelte (92%) rename src/routes/{ => (routes)}/machines/+page.ts (68%) rename src/routes/{ => (routes)}/machines/[id]/+page.svelte (99%) rename src/routes/{ => (routes)}/machines/[id]/+page.ts (78%) create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/oidc/callback/+server.ts create mode 100644 src/routes/oidc/start/+server.ts diff --git a/.env.example b/.env.example index d3c3a0a..f67b481 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ PUBLIC_HEADSCALE_URL=https://tailscale.example.com -PUBLIC_API_KEY=abcdefghijklmnopqrstuvwxyz +API_KEY=abcdefghijklmnopqrstuvwxyz +COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz +OIDC_CLIENT_ID=headscale +OIDC_CLIENT_ID=headscale +OIDC_ISSUER=https://sso.example.com +OIDC_CLIENT_SECRET=super_secret_client_secret diff --git a/package-lock.json b/package-lock.json index a772559..cd53935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@tanstack/svelte-query": "^5.28.4", "@zerodevx/svelte-toast": "^0.9.5", - "clsx": "^2.1.0" + "clsx": "^2.1.0", + "oauth4webapi": "^2.10.3" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.0.0", @@ -2830,6 +2831,14 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.10.3.tgz", + "integrity": "sha512-9FkXEXfzVKzH63GUOZz1zMr3wBaICSzk6DLXx+CGdrQ10ItNk2ePWzYYc1fdmKq1ayGFb2aX97sRCoZ2s0mkDw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index e4f999b..0285cc6 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dependencies": { "@tanstack/svelte-query": "^5.28.4", "@zerodevx/svelte-toast": "^0.9.5", - "clsx": "^2.1.0" + "clsx": "^2.1.0", + "oauth4webapi": "^2.10.3" } } diff --git a/src/app.d.ts b/src/app.d.ts index b4f491a..1af5f12 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,7 +1,10 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + apiKey: string; + } + // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..4d953d9 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,12 @@ +import { decryptCookie } from '$lib/crypto'; +import type { Handle } from '@sveltejs/kit'; +export const handle: Handle = async ({ event, resolve }) => { + const cookie = event.cookies.get('hs_api_key'); + if (cookie) { + const key = await decryptCookie(cookie); + event.locals.apiKey = key; + } + + const response = await resolve(event); + return response; +}; diff --git a/src/lib/api/apiKey.ts b/src/lib/api/apiKey.ts new file mode 100644 index 0000000..d183d4b --- /dev/null +++ b/src/lib/api/apiKey.ts @@ -0,0 +1,10 @@ +import { post } from "."; + +export async function generateApiKey(exp: number, key: string) { + const expDate = new Date(exp * 1000).toISOString(); + const response = await post<{ apiKey: string }>('v1/apikey', key, { + expiration: expDate + }); + + return response.apiKey; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8905310..dd535a3 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,9 +1,7 @@ import { env } from '$env/dynamic/public' -export async function pull(url: string) { +export async function pull(url: string, key: string) { const prefix = env.PUBLIC_HEADSCALE_URL - const key = env.PUBLIC_API_KEY - const res = await fetch(`${prefix}/api/${url}`, { headers: { 'Authorization': `Bearer ${key}` @@ -16,3 +14,23 @@ export async function pull(url: string) { return res.json() as Promise } + +export async function post(url: string, key: string, body?: any) { + const prefix = env.PUBLIC_HEADSCALE_URL + + const res = await fetch(`${prefix}/api/${url}`, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + headers: { + 'Authorization': `Bearer ${key}` + } + }) + + if (!res.ok) { + throw new Error(await res.text()) + } + + return res.json() as Promise +} + +export * from './apiKey' diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..2c8f55a --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,87 @@ +import { env } from "$env/dynamic/private" + +export async function encryptCookie(cookieValue: string) { + const password = env.COOKIE_SECRET; + + const salt = crypto.getRandomValues(new Uint8Array(16)); // Generate a random salt + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"] + ); + + const derivedKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-CBC", length: 256 }, + false, + ["encrypt"] + ); + + const iv = crypto.getRandomValues(new Uint8Array(16)); + + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-CBC", + iv: iv, + }, + derivedKey, + new TextEncoder().encode(cookieValue) + ); + + const combinedData = new Uint8Array(salt.byteLength + iv.byteLength + encrypted.byteLength); + combinedData.set(salt, 0); + combinedData.set(iv, salt.byteLength); + combinedData.set(new Uint8Array(encrypted), salt.byteLength + iv.byteLength); + const numberArray = Array.from(new Uint8Array(combinedData)); + + return btoa(String.fromCharCode.apply(null, numberArray)); +} + +export async function decryptCookie(encryptedCookie: string) { + const password = env.COOKIE_SECRET; + const encryptedData = Uint8Array.from(atob(encryptedCookie), c => c.charCodeAt(0)); + const salt = encryptedData.slice(0, 16); + const iv = encryptedData.slice(16, 32); + const encrypted = encryptedData.slice(32); + + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"] + ); + + const derivedKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-CBC", length: 256 }, + false, + ["decrypt"] + ); + + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: iv, + }, + derivedKey, + encrypted + ); + + return new TextDecoder().decode(decrypted); +} + diff --git a/src/routes/(routes)/+layout.svelte b/src/routes/(routes)/+layout.svelte new file mode 100644 index 0000000..ba91ffc --- /dev/null +++ b/src/routes/(routes)/+layout.svelte @@ -0,0 +1,25 @@ + + +
+ +
+ +
+ +
diff --git a/src/routes/machines/+page.svelte b/src/routes/(routes)/machines/+page.svelte similarity index 92% rename from src/routes/machines/+page.svelte rename to src/routes/(routes)/machines/+page.svelte index 368a354..cebdf63 100644 --- a/src/routes/machines/+page.svelte +++ b/src/routes/(routes)/machines/+page.svelte @@ -1,16 +1,22 @@ diff --git a/src/routes/machines/+page.ts b/src/routes/(routes)/machines/+page.ts similarity index 68% rename from src/routes/machines/+page.ts rename to src/routes/(routes)/machines/+page.ts index 52c331d..a61e74f 100644 --- a/src/routes/machines/+page.ts +++ b/src/routes/(routes)/machines/+page.ts @@ -3,13 +3,17 @@ import type { Machine } from "$lib/types"; import type { PageLoad } from './$types'; export async function load({ parent }: Parameters[0]) { - const { queryClient } = await parent(); + const { queryClient, apiKey } = await parent(); await queryClient.prefetchQuery({ queryKey: ['machines'], queryFn: async () => { - const data = await pull<{ nodes: Machine[] }>('v1/node'); + const data = await pull<{ nodes: Machine[] }>('v1/node', apiKey); return data.nodes; }, }); + + return { + apiKey, + }; } diff --git a/src/routes/machines/[id]/+page.svelte b/src/routes/(routes)/machines/[id]/+page.svelte similarity index 99% rename from src/routes/machines/[id]/+page.svelte rename to src/routes/(routes)/machines/[id]/+page.svelte index 4e46669..47cf477 100644 --- a/src/routes/machines/[id]/+page.svelte +++ b/src/routes/(routes)/machines/[id]/+page.svelte @@ -13,6 +13,7 @@ queryFn: async () => { const response = await pull<{ node: Machine }>( `v1/node/${data.id}`, + data.apiKey, ); return response.node; }, diff --git a/src/routes/machines/[id]/+page.ts b/src/routes/(routes)/machines/[id]/+page.ts similarity index 78% rename from src/routes/machines/[id]/+page.ts rename to src/routes/(routes)/machines/[id]/+page.ts index 7d5eba5..8aed319 100644 --- a/src/routes/machines/[id]/+page.ts +++ b/src/routes/(routes)/machines/[id]/+page.ts @@ -3,15 +3,18 @@ import type { Machine } from "$lib/types"; import type { PageLoad } from './$types'; export async function load({ parent, params }: Parameters[0]) { - const { queryClient } = await parent(); + const { queryClient, apiKey } = await parent(); await queryClient.prefetchQuery({ queryKey: [`machines/${params.id}`], queryFn: async () => { - const data = await pull<{ node: Machine }>(`v1/node/${params.id}`); + const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, apiKey); return data.node; }, }); - return { id: params.id } + return { + id: params.id, + apiKey, + } } diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index c34606c..691ba6c 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -2,8 +2,16 @@ import { redirect } from "@sveltejs/kit"; import type { LayoutServerLoad } from './$types'; import { base } from "$app/paths"; -export async function load({ url }: Parameters[0]) { +export async function load({ url, locals }: Parameters[0]) { if (url.pathname === base) { redirect(307, `${base}/machines`); } + + if (!locals.apiKey && url.pathname !== `${base}/login`) { + redirect(307, `${base}/login`); + } + + return { + apiKey: locals.apiKey, + }; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 509946c..f365a23 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,18 +1,11 @@ -
- -
- -
- -
+
+ import { base } from "$app/paths"; + + + + Login + + +
+
+

Login

+

+ Enter an API key to authenticate with Headplane. You can generate + one by running + + headscale apikeys create + + in your terminal. +

+ + +
+
+ or +
+
+ + + +
+
diff --git a/src/routes/oidc/callback/+server.ts b/src/routes/oidc/callback/+server.ts new file mode 100644 index 0000000..25d936e --- /dev/null +++ b/src/routes/oidc/callback/+server.ts @@ -0,0 +1,84 @@ +import { json, redirect } from '@sveltejs/kit' +import * as oauth from 'oauth4webapi' +import type { RequestHandler } from './$types' +import { generateApiKey } from '$lib/api' +import { env as publicEnv } from '$env/dynamic/public' +import { base } from "$app/paths"; +import { encryptCookie } from '$lib/crypto' +import { env } from '$env/dynamic/private' + +export async function GET({ url, cookies }: Parameters[0]) { + const issuer = new URL(env.OIDC_ISSUER) + const client = { + client_id: env.OIDC_CLIENT_ID, + client_secret: env.OIDC_CLIENT_SECRET, + token_endpoint_auth_method: 'client_secret_basic', + } satisfies oauth.Client + + const response = await oauth.discoveryRequest(issuer, { algorithm: 'oidc' }) + const res = await oauth.processDiscoveryResponse(issuer, response) + if (!res.authorization_endpoint) { + return json({ status: 'Error', message: "No authorization endpoint found" }, { + status: 400, + }) + } + + const oidc = cookies.get('oidc_state') + if (!oidc) { + return json({ status: 'Error', message: "No state found" }, { + status: 400, + }) + } + + const [state, nonce, verifier] = oidc.split(':') + + const params = oauth.validateAuthResponse(res, client, url, state) + if (oauth.isOAuth2Error(params)) { + console.error(params) + return json({ status: 'Error', message: "Invalid response" }, { + status: 400, + }) + } + + const callback = new URL(`${base}/oidc/callback`, url.origin) + const token_response = await oauth.authorizationCodeGrantRequest( + res, + client, + params, + callback.href, + verifier + ) + + const challenges = oauth.parseWwwAuthenticateChallenges(token_response) + if (challenges) { + return json({ status: 'Error', message: "Invalid response", challenges }, { + status: 401, + }) + } + + const result = await oauth.processAuthorizationCodeOpenIDResponse(res, client, token_response, nonce) + if (oauth.isOAuth2Error(result)) { + return json({ status: 'Error', message: "Invalid response", result }, { + status: 400, + }) + } + + const claims = oauth.getValidatedIdTokenClaims(result) + + // Generate an API key for the user that expires in claims.exp + const key = await generateApiKey(claims.exp, env.API_KEY); + + const value = await encryptCookie(key); + cookies.set('hs_api_key', value, { + path: base, + expires: new Date(claims.exp * 1000), + sameSite: 'lax', + httpOnly: true, + }); + + cookies.delete('oidc_state', { + path: `${base}/oidc` + }); + + return redirect(307, `${base}/machines`); +} diff --git a/src/routes/oidc/start/+server.ts b/src/routes/oidc/start/+server.ts new file mode 100644 index 0000000..5e24ea7 --- /dev/null +++ b/src/routes/oidc/start/+server.ts @@ -0,0 +1,42 @@ +import { json, redirect } from '@sveltejs/kit' +import * as oauth from 'oauth4webapi' +import type { RequestHandler } from './$types' +import { env } from '$env/dynamic/private' +import { base } from '$app/paths' + +export async function GET({ url, cookies }: Parameters[0]) { + const issuer = new URL(env.OIDC_ISSUER) + const client = { + client_id: env.OIDC_CLIENT_ID, + token_endpoint_auth_method: 'client_secret_basic', + } satisfies oauth.Client + + const response = await oauth.discoveryRequest(issuer) + const res = await oauth.processDiscoveryResponse(issuer, response) + if (!res.authorization_endpoint) { + return json({ status: "Error", message: "No authorization endpoint found" }, { + status: 400, + }) + } + + const state = oauth.generateRandomState() + const nonce = oauth.generateRandomNonce() + const verifier = oauth.generateRandomCodeVerifier() + const challenge = await oauth.calculatePKCECodeChallenge(verifier) + + const callback = new URL(`${base}/oidc/callback`, url.origin) + const auth_url = new URL(res.authorization_endpoint) + auth_url.searchParams.set('client_id', client.client_id) + auth_url.searchParams.set('response_type', 'code') + auth_url.searchParams.set('redirect_uri', callback.href) + auth_url.searchParams.set('scope', 'openid profile email') + auth_url.searchParams.set('code_challenge', challenge) + auth_url.searchParams.set('code_challenge_method', 'S256') + auth_url.searchParams.set('state', state) + auth_url.searchParams.set('nonce', nonce) + + cookies.set('oidc_state', `${state}:${nonce}:${verifier}`, { + path: base + }) + return redirect(302, auth_url.toString()) +}