Compare commits

...

3 Commits

10 changed files with 207 additions and 56 deletions

View File

@ -18,7 +18,7 @@ and [wg-quick](https://www.wireguard.com/quickstart/) for standalone setups.
## Development ## Development
Development uses bun. Development uses bun.
An additional prepare step is needed to set up typia for type validation. An additional prepare step is needed to set up typia for type validation.
For example .env settings, see [.env.example](.env.example) For example .env settings, see [.env.example](.env.example)
@ -27,3 +27,10 @@ bun install
bun run prepare bun run prepare
bun run dev bun run dev
``` ```
## To Do
- [ ] Proper invite page
- [ ] Proper error page for login without invite
- [ ] Support file provider (for wg-quick)
- [ ] wg-quick scripts (maybe?)

View File

@ -4,21 +4,21 @@
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';
import googleIcon from '$lib/assets/google.svg'; import googleIcon from '$lib/assets/google.svg';
let { inviteToken, class: className, ...rest }: { inviteToken?: string; class?: string; rest?: { [p: string]: unknown } } = $props(); let { inviteToken, class: className, ...rest }: {
inviteToken?: string;
class?: string;
rest?: { [p: string]: unknown }
} = $props();
let isLoading = $state(false); let submitted = $state(false);
</script> </script>
<div class={cn('flex gap-6', className)} {...rest}> <div class={cn('flex gap-6', className)} {...rest}>
<form method="get" action="/auth/authentik{inviteToken ? `?invite=${inviteToken}` : ''}"> <form method="get" onsubmit={() => submitted = true}
action="/auth/authentik{inviteToken ? `?invite=${inviteToken}` : ''}">
<input type="hidden" value={inviteToken} name="invite" /> <input type="hidden" value={inviteToken} name="invite" />
<Button <Button type="submit" disabled={submitted}>
type="submit" {#if submitted}
onclick={() => {
isLoading = true;
}}
>
{#if isLoading}
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" /> <LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else} {:else}
<img <img
@ -30,15 +30,11 @@
Sign in with Authentik Sign in with Authentik
</Button> </Button>
</form> </form>
<form method="get" action="/auth/google"> <form method="get" onsubmit={() => submitted = true}
action="/auth/google{inviteToken ? `?invite=${inviteToken}` : ''}">
<input type="hidden" value={inviteToken} name="invite" /> <input type="hidden" value={inviteToken} name="invite" />
<Button <Button type="submit" disabled={submitted}>
type="submit" {#if submitted}
onclick={() => {
isLoading = true;
}}
>
{#if isLoading}
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" /> <LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else} {:else}
<img <img

View File

@ -8,7 +8,7 @@ export const GET: RequestHandler = ({ url, cookies }) => {
const state = generateState(); const state = generateState();
const codeVerifier = generateCodeVerifier(); const codeVerifier = generateCodeVerifier();
const scopes = ['openid', 'profile', 'email']; const scopes = ['openid', 'profile', 'email'];
const authUrl = google.createAuthorizationURL(state, codeVerifier, scopes); const authUrl = google.createAuthorizationURL(state + inviteToken, codeVerifier, scopes);
cookies.set('google_oauth_state', state, { cookies.set('google_oauth_state', state, {
path: '/', path: '/',
@ -22,12 +22,6 @@ export const GET: RequestHandler = ({ url, cookies }) => {
maxAge: 60 * 10, // 10 minutes maxAge: 60 * 10, // 10 minutes
sameSite: 'lax', sameSite: 'lax',
}); });
if (inviteToken !== null) cookies.set('invite_token', inviteToken, {
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
return new Response(null, { return new Response(null, {
status: 302, status: 302,

View File

@ -0,0 +1,108 @@
import { error } from '@sveltejs/kit';
import * as arctic from 'arctic';
import { google } from '$lib/server/oauth';
import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm';
import * as table from '$lib/server/db/schema';
import { createSession, isValidInviteToken, setSessionTokenCookie } from '$lib/server/auth';
import type { OAuth2Tokens } from 'arctic';
import { assertGuard } from 'typia';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, cookies }) => {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies.get('google_oauth_state') ?? null;
const codeVerifier = cookies.get('google_code_verifier') ?? null;
if (code === null || state === null || storedState === null || codeVerifier === null) {
error(400, 'Missing url parameters');
return;
}
const stateGeneratedToken = state.slice(0, storedState.length);
const stateInviteToken = state.slice(storedState.length);
if (stateGeneratedToken !== storedState) {
return new Response(null, {
status: 400,
});
}
let tokens: OAuth2Tokens;
try {
tokens = await google.validateAuthorizationCode(code, codeVerifier);
} catch (e) {
if (e instanceof arctic.OAuth2RequestError) {
console.debug('Arctic: OAuth: invalid authorization code, credentials, or redirect URI', e);
return new Response(null, {
status: 400,
});
}
if (e instanceof arctic.ArcticFetchError) {
console.debug('Arctic: failed to call `fetch()`', e);
return new Response(null, {
status: 400,
});
}
return new Response(null, {
status: 500,
});
}
const idToken = tokens.idToken();
const claims = arctic.decodeIdToken(idToken);
console.log('claims', claims);
assertGuard<{
sub: string;
email: string;
name: string;
}>(claims);
const userId = claims.sub;
const existingUser = await db.query.users.findFirst({ where: eq(table.users.id, userId) });
if (existingUser) {
const session = await createSession(existingUser.id);
setSessionTokenCookie(event, session.id, session.expiresAt);
return new Response(null, {
status: 302,
headers: {
Location: '/',
},
});
}
if (!isValidInviteToken(stateInviteToken)) {
const message =
stateInviteToken.length === 0 ? 'sign up with an invite link first' : 'invalid invite link';
return new Response('Not Authorized: ' + message, {
status: 403,
});
}
const user: table.User = {
id: userId,
authSource: 'google',
username: claims.email,
name: claims.name,
};
// TODO: proper error handling, delete cookies
await db.insert(table.users).values(user);
console.log('created user', user, 'with invite token', stateInviteToken);
const session = await createSession(user.id);
setSessionTokenCookie(event, session.id, session.expiresAt);
return new Response(null, {
status: 302,
headers: {
Location: '/',
},
});
};

View File

@ -13,8 +13,7 @@ export const GET: RequestHandler = async (event) => {
const code = url.searchParams.get('code'); const code = url.searchParams.get('code');
const state = url.searchParams.get('state'); const state = url.searchParams.get('state');
const storedState = cookies.get('google_oauth_state') ?? null; const storedState = cookies.get('google_oauth_state') ?? null;
const codeVerifier = cookies.get('google_code_verifier', ) ?? null; const codeVerifier = cookies.get('google_code_verifier') ?? null;
const inviteToken = cookies.get('invite_token') ?? null;
if (code === null || state === null || storedState === null || codeVerifier === null) { if (code === null || state === null || storedState === null || codeVerifier === null) {
return new Response(null, { return new Response(null, {
@ -22,6 +21,14 @@ export const GET: RequestHandler = async (event) => {
}); });
} }
const stateGeneratedToken = state.slice(0, storedState.length);
const stateInviteToken = state.slice(storedState.length);
if (stateGeneratedToken !== storedState) {
return new Response(null, {
status: 400,
});
}
let tokens: OAuth2Tokens; let tokens: OAuth2Tokens;
try { try {
tokens = await google.validateAuthorizationCode(code, codeVerifier); tokens = await google.validateAuthorizationCode(code, codeVerifier);
@ -69,10 +76,12 @@ export const GET: RequestHandler = async (event) => {
}); });
} }
// TODO: proper error page if (!isValidInviteToken(stateInviteToken)) {
if (inviteToken === null || !isValidInviteToken(inviteToken)) { const message =
return new Response(null, { stateInviteToken.length === 0 ? 'sign up with an invite link first' : 'invalid invite link';
status: 400,
return new Response('Not Authorized: ' + message, {
status: 403,
}); });
} }
@ -85,7 +94,7 @@ export const GET: RequestHandler = async (event) => {
// TODO: proper error handling, delete cookies // TODO: proper error handling, delete cookies
await db.insert(table.users).values(user); await db.insert(table.users).values(user);
console.log('created user', user, 'with invite token', inviteToken); console.log('created user', user, 'with invite token', stateInviteToken);
const session = await createSession(user.id); const session = await createSession(user.id);

View File

@ -0,0 +1,47 @@
import type { PageServerLoad } from './$types';
import type { ConnectionDetails } from '$lib/connections';
import { findDevices } from '$lib/server/devices';
import wgProvider from '$lib/server/wg-provider';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, setHeaders, depends }) => {
if (!locals.user) {
error(401, 'Unauthorized');
}
console.debug('/connections');
const peersResult = await wgProvider.findConnections(locals.user);
if (peersResult._tag === 'err') return error(500, peersResult.error.message);
const devices = await findDevices(locals.user.id);
console.debug('/connections: fetched db devices');
// TODO: this is all garbage performance
// filter devices with no recent handshakes
const peers = peersResult.value.filter((peer) => peer.latestHandshake);
// start from devices, to treat db as the source of truth
const connections: ConnectionDetails[] = [];
for (const device of devices) {
const peerData = peers.find((peer) => peer.publicKey === device.publicKey);
if (!peerData) continue;
connections.push({
deviceId: device.id,
deviceName: device.name,
devicePublicKey: device.publicKey,
deviceIps: peerData.allowedIps.split(','),
endpoint: peerData.endpoint,
// swap rx and tx, since the opnsense values are from the server perspective
transferRx: peerData.transferTx,
transferTx: peerData.transferRx,
latestHandshake: peerData.latestHandshake,
});
}
setHeaders({
'Cache-Control': 'max-age=5',
});
depends('connections');
return { connections };
};

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { invalidate } from '$app/navigation'; import { invalidate, invalidateAll } from '$app/navigation';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -10,7 +10,7 @@
// refresh every 5 seconds // refresh every 5 seconds
const interval = setInterval(() => { const interval = setInterval(() => {
console.log('Refreshing connections'); console.log('Refreshing connections');
invalidate('/api/connections'); invalidate('/connections');
}, 5000); }, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);

View File

@ -1,9 +0,0 @@
import type { PageLoad } from './$types';
import type { ConnectionDetails } from '$lib/connections';
export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/connections');
const connections = await res.json() as ConnectionDetails[];
return { connections };
};

View File

@ -1,7 +1,15 @@
import type { Actions } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { createDevice } from '$lib/server/devices'; import { createDevice, findDevices, mapDeviceToDetails } from '$lib/server/devices';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import wgProvider from '$lib/server/wg-provider';
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
error(401, 'Unauthorized');
}
const devices = await findDevices(event.locals.user.id);
return { devices };
};
export const actions = { export const actions = {
create: async (event) => { create: async (event) => {

View File

@ -1,9 +0,0 @@
import type { PageLoad } from './$types';
import type { DeviceDetails } from '$lib/devices';
export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/devices');
const { devices } = await res.json() as { devices: DeviceDetails[] };
return { devices };
};