1 Commits

Author SHA1 Message Date
190b1d0c90 WIP: auth: refactor to page routes instead of api routes 2025-04-12 01:34:07 -07:00
11 changed files with 70 additions and 227 deletions

View File

@@ -33,4 +33,8 @@ bun run dev
- [ ] Proper invite page - [ ] Proper invite page
- [ ] Proper error page for login without invite - [ ] Proper error page for login without invite
- [ ] Support file provider (for wg-quick) - [ ] Support file provider (for wg-quick)
- [ ] Fix accessibility issues (lighthouse)
- [ ] Add title to profile page (lighthouse)
- [ ] wg-quick scripts (maybe?) - [ ] wg-quick scripts (maybe?)
- [ ] Get rid of api routes (maybe?)
- [ ] Get rid of custom result types (maybe?)

View File

@@ -3,7 +3,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import type { RequestEvent } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
@@ -22,14 +22,14 @@ export async function createSession(userId: string): Promise<table.Session> {
const session: table.Session = { const session: table.Session = {
id: sessionId, id: sessionId,
userId, userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30) expiresAt: new Date(Date.now() + DAY_IN_MS * 30),
}; };
await db.insert(table.sessions).values(session); await db.insert(table.sessions).values(session);
return session; return session;
} }
export function setSessionTokenCookie(event: RequestEvent, sessionId: string, expiresAt: Date) { export function setSessionTokenCookie(cookies: Cookies, sessionId: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, sessionId, { cookies.set(sessionCookieName, sessionId, {
path: '/', path: '/',
sameSite: 'lax', sameSite: 'lax',
httpOnly: true, httpOnly: true,
@@ -42,16 +42,21 @@ export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(table.sessions).where(eq(table.sessions.id, sessionId)); await db.delete(table.sessions).where(eq(table.sessions.id, sessionId));
} }
export function deleteSessionTokenCookie(event: RequestEvent) { export function deleteSessionTokenCookie(cookies: Cookies) {
event.cookies.delete(sessionCookieName, { path: '/' }); cookies.delete(sessionCookieName, { path: '/' });
} }
export async function validateSession(sessionId: string) { export async function validateSession(sessionId: string) {
const [result] = await db const [result] = await db
.select({ .select({
// Adjust user table here to tweak returned data // Adjust user table here to tweak returned data
user: { id: table.users.id, authSource: table.users.authSource, username: table.users.username, name: table.users.name }, user: {
session: table.sessions id: table.users.id,
authSource: table.users.authSource,
username: table.users.username,
name: table.users.name,
},
session: table.sessions,
}) })
.from(table.sessions) .from(table.sessions)
.innerJoin(table.users, eq(table.sessions.userId, table.users.id)) .innerJoin(table.users, eq(table.sessions.userId, table.users.id))

View File

@@ -1,14 +1,14 @@
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from '@sveltejs/kit';
import { invalidateSession, deleteSessionTokenCookie } from "$lib/server/auth"; import { invalidateSession, deleteSessionTokenCookie } from '$lib/server/auth';
import type { Actions } from "./$types"; import type { Actions } from './$types';
export const actions: Actions = { export const actions: Actions = {
logout: async (event) => { logout: async ({ locals, cookies }) => {
if (event.locals.session === null) { if (locals.session === null) {
return fail(401); return fail(401);
} }
await invalidateSession(event.locals.session.id); await invalidateSession(locals.session.id);
deleteSessionTokenCookie(event); deleteSessionTokenCookie(cookies);
return redirect(302, "/"); redirect(302, '/');
} },
}; };

View File

@@ -48,7 +48,7 @@ export async function GET(event: RequestEvent): Promise<Response> {
if (existingUser) { if (existingUser) {
const session = await createSession(existingUser.id); const session = await createSession(existingUser.id);
setSessionTokenCookie(event, session.id, session.expiresAt); setSessionTokenCookie(event.cookies, session.id, session.expiresAt);
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
@@ -67,7 +67,7 @@ export async function GET(event: RequestEvent): Promise<Response> {
try { try {
await db.insert(table.users).values(user); await db.insert(table.users).values(user);
const session = await createSession(user.id); const session = await createSession(user.id);
setSessionTokenCookie(event, session.id, session.expiresAt); setSessionTokenCookie(event.cookies, session.id, session.expiresAt);
} catch (e) { } catch (e) {
console.error('failed to create user', e); console.error('failed to create user', e);
return new Response(null, { return new Response(null, {

View File

@@ -1,15 +1,17 @@
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 { createSession, isValidInviteToken, setSessionTokenCookie } from '$lib/server/auth';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { google } from '$lib/server/oauth';
import { error, redirect } from '@sveltejs/kit';
import type { OAuth2Tokens } from 'arctic'; import type { OAuth2Tokens } from 'arctic';
import * as arctic from 'arctic';
import { eq } from 'drizzle-orm';
import { assertGuard } from 'typia'; import { assertGuard } from 'typia';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, cookies }) => { export const load: PageServerLoad = async (event) => {
const { url, cookies } = 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;
@@ -17,15 +19,12 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
if (code === null || state === null || storedState === null || codeVerifier === null) { if (code === null || state === null || storedState === null || codeVerifier === null) {
error(400, 'Missing url parameters'); error(400, 'Missing url parameters');
return;
} }
const stateGeneratedToken = state.slice(0, storedState.length); const stateGeneratedToken = state.slice(0, storedState.length);
const stateInviteToken = state.slice(storedState.length); const stateInviteToken = state.slice(storedState.length);
if (stateGeneratedToken !== storedState) { if (stateGeneratedToken !== storedState) {
return new Response(null, { error(400, 'Invalid state in url');
status: 400,
});
} }
let tokens: OAuth2Tokens; let tokens: OAuth2Tokens;
@@ -34,20 +33,14 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
} catch (e) { } catch (e) {
if (e instanceof arctic.OAuth2RequestError) { if (e instanceof arctic.OAuth2RequestError) {
console.debug('Arctic: OAuth: invalid authorization code, credentials, or redirect URI', e); console.debug('Arctic: OAuth: invalid authorization code, credentials, or redirect URI', e);
return new Response(null, { throw error(400, 'Invalid authorization code');
status: 400,
});
} }
if (e instanceof arctic.ArcticFetchError) { if (e instanceof arctic.ArcticFetchError) {
console.debug('Arctic: failed to call `fetch()`', e); console.debug('Arctic: failed to call `fetch()`', e);
return new Response(null, { error(400, 'Failed to validate authorization code');
status: 400,
});
} }
console.error('Unxepcted error validating authorization code', code, e);
return new Response(null, { error(500);
status: 500,
});
} }
const idToken = tokens.idToken(); const idToken = tokens.idToken();
@@ -66,22 +59,14 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
if (existingUser) { if (existingUser) {
const session = await createSession(existingUser.id); const session = await createSession(existingUser.id);
setSessionTokenCookie(event, session.id, session.expiresAt); setSessionTokenCookie(cookies, session.id, session.expiresAt);
return new Response(null, { redirect(302, '/');
status: 302,
headers: {
Location: '/',
},
});
} }
if (!isValidInviteToken(stateInviteToken)) { if (!isValidInviteToken(stateInviteToken)) {
const message = const message =
stateInviteToken.length === 0 ? 'sign up with an invite link first' : 'invalid invite link'; stateInviteToken.length === 0 ? 'sign up with an invite link first' : 'invalid invite link';
error(403, 'Not Authorized: ' + message);
return new Response('Not Authorized: ' + message, {
status: 403,
});
} }
const user: table.User = { const user: table.User = {
@@ -97,12 +82,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
const session = await createSession(user.id); const session = await createSession(user.id);
setSessionTokenCookie(event, session.id, session.expiresAt); setSessionTokenCookie(cookies, session.id, session.expiresAt);
return new Response(null, { redirect(302, '/');
status: 302,
headers: {
Location: '/',
},
});
}; };

View File

@@ -1,109 +0,0 @@
import type { RequestHandler } from './$types';
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';
export const GET: RequestHandler = async (event) => {
const { url, cookies } = event;
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) {
return new Response(null, {
status: 400,
});
}
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

@@ -1,47 +0,0 @@
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, invalidateAll } from '$app/navigation'; import { invalidate } 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('/connections'); invalidate('/api/connections');
}, 5000); }, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);

View File

@@ -0,0 +1,9 @@
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,15 +1,7 @@
import type { Actions, PageServerLoad } from './$types'; import type { Actions } from './$types';
import { createDevice, findDevices, mapDeviceToDetails } from '$lib/server/devices'; import { createDevice } 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

@@ -0,0 +1,9 @@
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 };
};