feat: add login via oidc or api key
This commit is contained in:
parent
2ff7736243
commit
8cce3258a2
@ -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
11
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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
5
src/app.d.ts
vendored
@ -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
12
src/hooks.server.ts
Normal 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
10
src/lib/api/apiKey.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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
87
src/lib/crypto.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
25
src/routes/(routes)/+layout.svelte
Normal file
25
src/routes/(routes)/+layout.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
},
|
},
|
||||||
@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/routes/login/+page.svelte
Normal file
38
src/routes/login/+page.svelte
Normal 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>
|
||||||
84
src/routes/oidc/callback/+server.ts
Normal file
84
src/routes/oidc/callback/+server.ts
Normal 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`);
|
||||||
|
}
|
||||||
42
src/routes/oidc/start/+server.ts
Normal file
42
src/routes/oidc/start/+server.ts
Normal 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())
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user