17 Commits
Author SHA1 Message Date
CaZzzer 8797238c4d ci: finalize woodpecker pipeline
ci/woodpecker/push/build-image Pipeline was successful
2025-04-23 17:08:20 -07:00
CaZzzer a5a840accc ci: enable kaniko cache
ci/woodpecker/push/build-image Pipeline was successful
2025-04-23 17:01:28 -07:00
CaZzzer 0cd67da306 ci: change image build tags to branch
ci/woodpecker/push/build-image Pipeline was successful
2025-04-23 16:58:15 -07:00
CaZzzer 2519fec3c4 ci: change image build tags to branch 2025-04-23 16:57:37 -07:00
CaZzzer 7fea7dd00c ci: change image build tags to branch
ci/woodpecker/push/build-image Pipeline failed
2025-04-23 16:56:17 -07:00
CaZzzer fbc93d87e0 ci: change image build tags to branch 2025-04-23 16:54:18 -07:00
CaZzzer ee261142e8 fix docker builds
ci/woodpecker/push/build-image Pipeline was successful
2025-04-21 23:18:02 -07:00
CaZzzer 101c53709d add woodpecker ci - kaniko
ci/woodpecker/push/build-image Pipeline failed
2025-04-21 22:58:03 -07:00
CaZzzer 0baedd2d0e add woodpecker ci - kaniko
ci/woodpecker/push/build-image Pipeline failed
2025-04-21 22:56:50 -07:00
CaZzzer 5affe758ad add woodpecker ci - kaniko
ci/woodpecker/push/build-image Pipeline failed
2025-04-21 22:55:14 -07:00
CaZzzer 501d967115 add woodpecker ci - kaniko
ci/woodpecker/push/build-image Pipeline failed
2025-04-21 22:50:44 -07:00
CaZzzer 1e2613b617 add woodpecker ci - kaniko
ci/woodpecker/push/build-image Pipeline failed
2025-04-21 22:48:25 -07:00
CaZzzer 5db86ca285 add woodpecker ci - buildx
ci/woodpecker/push/build-image Pipeline failed
2025-04-21 22:43:03 -07:00
CaZzzer 700e5857ae add woodpecker ci
ci/woodpecker/push/build-image Pipeline failed
2025-04-21 22:15:30 -07:00
CaZzzer 190b1d0c90 WIP: auth: refactor to page routes instead of api routes 2025-04-12 01:34:07 -07:00
CaZzzer 5b72ab28dd WIP: temp 2025-04-11 18:43:51 -07:00
CaZzzer 8f20c168a2 WIP: auth: improve handling of invite tokens 2025-04-11 18:39:45 -07:00
8 changed files with 82 additions and 88 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ steps:
settings: settings:
registry: gitea.cazzzer.com registry: gitea.cazzzer.com
repo: ${CI_REPO,,} repo: ${CI_REPO,,}
# replace '/' in branch name # replace invalid characters in branch name
tags: ${CI_COMMIT_BRANCH/\//-} tags: ${CI_COMMIT_BRANCH/\//-}
cache: true cache: true
username: username:
+12 -1
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,14 @@ 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)
- [ ] Fix accessibility issues (lighthouse)
- [ ] Add title to profile page (lighthouse)
- [ ] wg-quick scripts (maybe?)
- [ ] Get rid of api routes (maybe?)
- [ ] Get rid of custom result types (maybe?)
@@ -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
+13 -8
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))
+9 -9
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, '/');
} },
}; };
@@ -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, {
+1 -7
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,
@@ -1,25 +1,30 @@
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 { 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';
export const GET: RequestHandler = async (event) => { export const load: PageServerLoad = async (event) => {
const { url, cookies } = 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;
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, { error(400, 'Missing url parameters');
status: 400, }
});
const stateGeneratedToken = state.slice(0, storedState.length);
const stateInviteToken = state.slice(storedState.length);
if (stateGeneratedToken !== storedState) {
error(400, 'Invalid state in url');
} }
let tokens: OAuth2Tokens; let tokens: OAuth2Tokens;
@@ -28,20 +33,14 @@ export const GET: RequestHandler = async (event) => {
} 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();
@@ -60,20 +59,14 @@ export const GET: RequestHandler = async (event) => {
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: '/',
},
});
} }
// 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, error(403, 'Not Authorized: ' + message);
});
} }
const user: table.User = { const user: table.User = {
@@ -85,16 +78,11 @@ 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);
setSessionTokenCookie(event, session.id, session.expiresAt); setSessionTokenCookie(cookies, session.id, session.expiresAt);
return new Response(null, { redirect(302, '/');
status: 302,
headers: {
Location: '/',
},
});
}; };