feat: add login via oidc or api key

This commit is contained in:
Aarnav Tale 2024-03-18 13:06:45 -04:00
parent 2ff7736243
commit 8cce3258a2
No known key found for this signature in database
GPG Key ID: 1CA889B6ACCAF2F2
19 changed files with 378 additions and 40 deletions

View File

@ -1,2 +1,7 @@
PUBLIC_HEADSCALE_URL=https://tailscale.example.com 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

11
package-lock.json generated
View File

@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"@tanstack/svelte-query": "^5.28.4", "@tanstack/svelte-query": "^5.28.4",
"@zerodevx/svelte-toast": "^0.9.5", "@zerodevx/svelte-toast": "^0.9.5",
"clsx": "^2.1.0" "clsx": "^2.1.0",
"oauth4webapi": "^2.10.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
@ -2830,6 +2831,14 @@
"node": ">=0.10.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@ -30,6 +30,7 @@
"dependencies": { "dependencies": {
"@tanstack/svelte-query": "^5.28.4", "@tanstack/svelte-query": "^5.28.4",
"@zerodevx/svelte-toast": "^0.9.5", "@zerodevx/svelte-toast": "^0.9.5",
"clsx": "^2.1.0" "clsx": "^2.1.0",
"oauth4webapi": "^2.10.3"
} }
} }

5
src/app.d.ts vendored
View File

@ -1,7 +1,10 @@
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} interface Locals {
apiKey: string;
}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

12
src/hooks.server.ts Normal file
View File

@ -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;
};

10
src/lib/api/apiKey.ts Normal file
View File

@ -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;
}

View File

@ -1,9 +1,7 @@
import { env } from '$env/dynamic/public' import { env } from '$env/dynamic/public'
export async function pull<T>(url: string) { export async function pull<T>(url: string, key: string) {
const prefix = env.PUBLIC_HEADSCALE_URL const prefix = env.PUBLIC_HEADSCALE_URL
const key = env.PUBLIC_API_KEY
const res = await fetch(`${prefix}/api/${url}`, { const res = await fetch(`${prefix}/api/${url}`, {
headers: { headers: {
'Authorization': `Bearer ${key}` 'Authorization': `Bearer ${key}`
@ -16,3 +14,23 @@ export async function pull<T>(url: string) {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
export async function post<T>(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<T>
}
export * from './apiKey'

87
src/lib/crypto.ts Normal file
View File

@ -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);
}

View File

@ -0,0 +1,25 @@
<script lang="ts">
import {
IconBrandFlightradar24,
IconServer,
IconUsers,
} from "@tabler/icons-svelte";
import Link from "$lib/components/Link.svelte";
</script>
<header class="bg-gray-800 text-white mb-16">
<nav class="container mx-auto">
<div class="flex items-center gap-x-2 mb-8 pt-4">
<IconBrandFlightradar24 stroke={1} size={24} />
<h1 class="text-2xl">Headplane</h1>
</div>
<div class="flex items-center gap-x-4">
<Link to="/machines" name="Machines" icon={IconServer} />
<Link to="/users" name="Users" icon={IconUsers} />
</div>
</nav>
</header>
<main class="container mx-auto">
<slot />
</main>

View File

@ -1,16 +1,22 @@
<script lang="ts"> <script lang="ts">
import { IconCircleFilled, IconCopy } from "@tabler/icons-svelte"; import { IconCircleFilled, IconCopy } from "@tabler/icons-svelte";
import type { PageData } from "./$types";
import { toast } from "@zerodevx/svelte-toast"; import { toast } from "@zerodevx/svelte-toast";
import type { Machine } from "$lib/types"; import type { Machine } from "$lib/types";
import clsx from "clsx"; import clsx from "clsx";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { pull } from "$lib/api"; import { pull } from "$lib/api";
export let data: PageData;
const query = createQuery({ const query = createQuery({
queryKey: ["machines"], queryKey: ["machines"],
queryFn: async () => { queryFn: async () => {
const data = await pull<{ nodes: Machine[] }>("v1/node"); const apiData = await pull<{ nodes: Machine[] }>(
return data.nodes; "v1/node",
data.apiKey,
);
return apiData.nodes;
}, },
}); });
</script> </script>

View File

@ -3,13 +3,17 @@ import type { Machine } from "$lib/types";
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export async function load({ parent }: Parameters<PageLoad>[0]) { export async function load({ parent }: Parameters<PageLoad>[0]) {
const { queryClient } = await parent(); const { queryClient, apiKey } = await parent();
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
queryKey: ['machines'], queryKey: ['machines'],
queryFn: async () => { queryFn: async () => {
const data = await pull<{ nodes: Machine[] }>('v1/node'); const data = await pull<{ nodes: Machine[] }>('v1/node', apiKey);
return data.nodes; return data.nodes;
}, },
}); });
return {
apiKey,
};
} }

View File

@ -13,6 +13,7 @@
queryFn: async () => { queryFn: async () => {
const response = await pull<{ node: Machine }>( const response = await pull<{ node: Machine }>(
`v1/node/${data.id}`, `v1/node/${data.id}`,
data.apiKey,
); );
return response.node; return response.node;
}, },

View File

@ -3,15 +3,18 @@ import type { Machine } from "$lib/types";
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export async function load({ parent, params }: Parameters<PageLoad>[0]) { export async function load({ parent, params }: Parameters<PageLoad>[0]) {
const { queryClient } = await parent(); const { queryClient, apiKey } = await parent();
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
queryKey: [`machines/${params.id}`], queryKey: [`machines/${params.id}`],
queryFn: async () => { 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 data.node;
}, },
}); });
return { id: params.id } return {
id: params.id,
apiKey,
}
} }

View File

@ -2,8 +2,16 @@ import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { base } from "$app/paths"; import { base } from "$app/paths";
export async function load({ url }: Parameters<LayoutServerLoad>[0]) { export async function load({ url, locals }: Parameters<LayoutServerLoad>[0]) {
if (url.pathname === base) { if (url.pathname === base) {
redirect(307, `${base}/machines`); redirect(307, `${base}/machines`);
} }
if (!locals.apiKey && url.pathname !== `${base}/login`) {
redirect(307, `${base}/login`);
}
return {
apiKey: locals.apiKey,
};
} }

View File

@ -1,18 +1,11 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import Link from "$lib/components/Link.svelte";
import { SvelteToast } from "@zerodevx/svelte-toast"; import { SvelteToast } from "@zerodevx/svelte-toast";
import { import {
type QueryClient, type QueryClient,
QueryClientProvider, QueryClientProvider,
} from "@tanstack/svelte-query"; } from "@tanstack/svelte-query";
import {
IconBrandFlightradar24,
IconServer,
IconUsers,
} from "@tabler/icons-svelte";
type Data = { type Data = {
queryClient: QueryClient; queryClient: QueryClient;
}; };
@ -20,23 +13,8 @@
export let data: Data; export let data: Data;
</script> </script>
<header class="bg-gray-800 text-white mb-16">
<nav class="container mx-auto">
<div class="flex items-center gap-x-2 mb-8 pt-4">
<IconBrandFlightradar24 stroke={1} size={24} />
<h1 class="text-2xl">Headplane</h1>
</div>
<div class="flex items-center gap-x-4">
<Link to="/machines" name="Machines" icon={IconServer} />
<Link to="/users" name="Users" icon={IconUsers} />
</div>
</nav>
</header>
<QueryClientProvider client={data.queryClient}> <QueryClientProvider client={data.queryClient}>
<main class="container mx-auto"> <slot />
<slot />
</main>
</QueryClientProvider> </QueryClientProvider>
<SvelteToast <SvelteToast

View File

@ -1,7 +1,8 @@
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { QueryClient } from '@tanstack/svelte-query' import { QueryClient } from '@tanstack/svelte-query'
import type { LayoutLoadEvent } from './$types'
export async function load() { export async function load({ data }: LayoutLoadEvent) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@ -11,5 +12,8 @@ export async function load() {
}, },
}) })
return { queryClient } return {
queryClient,
apiKey: data.apiKey,
}
} }

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { base } from "$app/paths";
</script>
<svelte:head>
<title>Login</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-1/3 border p-4 rounded-lg">
<h1 class="text-2xl">Login</h1>
<p class="text-sm text-gray-500 mt-8 mb-4">
Enter an API key to authenticate with Headplane. You can generate
one by running
<code class="bg-gray-100 p-1 rounded-md">
headscale apikeys create
</code>
in your terminal.
</p>
<input
type="text"
id="api-key"
class="border rounded-md p-2 w-full"
placeholder="API Key"
/>
<div class="flex items-center gap-x-2 py-2">
<hr class="flex-1" />
<span class="text-gray-500">or</span>
<hr class="flex-1" />
</div>
<a href={`${base}/oidc/start`}>
<button class="bg-blue-500 text-white rounded-md p-2 w-full">
Login with SSO
</button>
</a>
</div>
</div>

View File

@ -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<RequestHandler>[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`);
}

View File

@ -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<RequestHandler>[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())
}