diff --git a/.env.example b/.env.example index dbb6809..4ef10a6 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,17 @@ DATABASE_URL=file:local.db -AUTH_DOMAIN=auth.lab.cazzzer.com -AUTH_CLIENT_ID= -AUTH_CLIENT_SECRET= -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= +PUBLIC_AUTH_AUTHENTIK_ENABLE=1 +AUTH_AUTHENTIK_REQUIRE_INVITE=0 +AUTH_AUTHENTIK_DOMAIN=auth.lab.cazzzer.com +AUTH_AUTHENTIK_CLIENT_ID= +AUTH_AUTHENTIK_CLIENT_SECRET= -INVITE_TOKEN=GUjdsz9aREFTEBYDrA3AajUE8oVys2xW +PUBLIC_AUTH_GOOGLE_ENABLE=1 +AUTH_GOOGLE_REQUIRE_INVITE=1 +AUTH_GOOGLE_CLIENT_ID= +AUTH_GOOGLE_CLIENT_SECRET= + +AUTH_INVITE_TOKEN=GUjdsz9aREFTEBYDrA3AajUE8oVys2xW OPNSENSE_API_URL=https://opnsense.cazzzer.com OPNSENSE_API_KEY= diff --git a/README.md b/README.md index 37e7ff1..5cac9c2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ and [wg-quick](https://www.wireguard.com/quickstart/) for standalone setups. ## Development 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) diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..e14df0c --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,9 @@ +import { envToBool } from '$lib/utils'; +import { env } from '$env/dynamic/public'; + +export type AuthProvider = 'authentik' | 'google'; + +export const enabledAuthProviders: Record = { + authentik: envToBool(env.PUBLIC_AUTH_AUTHENTIK_ENABLE), + google: envToBool(env.PUBLIC_AUTH_GOOGLE_ENABLE), +}; diff --git a/src/lib/components/app/auth-form/auth-form.svelte b/src/lib/components/app/auth-form/auth-form.svelte index 4144d69..2a19451 100644 --- a/src/lib/components/app/auth-form/auth-form.svelte +++ b/src/lib/components/app/auth-form/auth-form.svelte @@ -3,22 +3,24 @@ import { Button } from '$lib/components/ui/button'; import { cn } from '$lib/utils.js'; import googleIcon from '$lib/assets/google.svg'; + import { enabledAuthProviders } from '$lib/auth'; - 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);
-
+ {#if enabledAuthProviders.authentik } + submitted = true} + action="/auth/authentik{inviteToken ? `?invite=${inviteToken}` : ''}"> -
diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index f7a56f9..e1791fa 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -3,7 +3,7 @@ import { sha256 } from '@oslojs/crypto/sha2'; import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; import { db } from '$lib/server/db'; 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 { env } from '$env/dynamic/private'; @@ -22,14 +22,14 @@ export async function createSession(userId: string): Promise { const session: table.Session = { id: sessionId, 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); return session; } -export function setSessionTokenCookie(event: RequestEvent, sessionId: string, expiresAt: Date) { - event.cookies.set(sessionCookieName, sessionId, { +export function setSessionTokenCookie(cookies: Cookies, sessionId: string, expiresAt: Date) { + cookies.set(sessionCookieName, sessionId, { path: '/', sameSite: 'lax', httpOnly: true, @@ -42,16 +42,21 @@ export async function invalidateSession(sessionId: string): Promise { await db.delete(table.sessions).where(eq(table.sessions.id, sessionId)); } -export function deleteSessionTokenCookie(event: RequestEvent) { - event.cookies.delete(sessionCookieName, { path: '/' }); +export function deleteSessionTokenCookie(cookies: Cookies) { + cookies.delete(sessionCookieName, { path: '/' }); } export async function validateSession(sessionId: string) { const [result] = await db .select({ // 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 }, - session: table.sessions + user: { + id: table.users.id, + authSource: table.users.authSource, + username: table.users.username, + name: table.users.name, + }, + session: table.sessions, }) .from(table.sessions) .innerJoin(table.users, eq(table.sessions.userId, table.users.id)) @@ -81,7 +86,7 @@ export async function validateSession(sessionId: string) { } export function isValidInviteToken(inviteToken: string) { - return inviteToken === env.INVITE_TOKEN; + return inviteToken === env.AUTH_INVITE_TOKEN; } export type SessionValidationResult = Awaited>; diff --git a/src/lib/server/oauth-providers/authentik.ts b/src/lib/server/oauth-providers/authentik.ts new file mode 100644 index 0000000..e000d23 --- /dev/null +++ b/src/lib/server/oauth-providers/authentik.ts @@ -0,0 +1,34 @@ +import { envToBool } from '$lib/utils'; +import { env } from '$env/dynamic/private'; +import { Authentik, decodeIdToken } from 'arctic'; +import { assertGuard } from 'typia'; +import type { IOAuthProvider } from '$lib/server/oauth'; + +const authentikProvider = new Authentik( + env.AUTH_AUTHENTIK_DOMAIN, + env.AUTH_AUTHENTIK_CLIENT_ID, + env.AUTH_AUTHENTIK_CLIENT_SECRET, + `${env.ORIGIN}/auth/authentik/callback`, +); + +export const authentik: IOAuthProvider = { + requireInvite: envToBool(env.AUTH_AUTHENTIK_REQUIRE_INVITE, true), + createAuthorizationURL: (state: string, codeVerifier: string) => { + const scopes = ['openid', 'profile']; + return authentikProvider.createAuthorizationURL(state, codeVerifier, scopes); + }, + validateAuthorizationCode: async (code: string, codeVerifier: string) => { + const tokens = await authentikProvider.validateAuthorizationCode(code, codeVerifier); + const claims = decodeIdToken(tokens.idToken()); + assertGuard<{ + sub: string; + name: string; + preferred_username: string; + }>(claims); + return { + sub: claims.sub, + name: claims.name, + username: claims.preferred_username, + }; + }, +}; diff --git a/src/lib/server/oauth-providers/google.ts b/src/lib/server/oauth-providers/google.ts new file mode 100644 index 0000000..8ca15b2 --- /dev/null +++ b/src/lib/server/oauth-providers/google.ts @@ -0,0 +1,33 @@ +import { decodeIdToken, Google } from 'arctic'; +import { env } from '$env/dynamic/private'; +import { envToBool } from '$lib/utils'; +import { assertGuard } from 'typia'; +import type { IOAuthProvider } from '$lib/server/oauth'; + +const googleProvider = new Google( + env.AUTH_GOOGLE_CLIENT_ID, + env.AUTH_GOOGLE_CLIENT_SECRET, + `${env.ORIGIN}/auth/google/callback`, +); + +export const google: IOAuthProvider = { + requireInvite: envToBool(env.AUTH_GOOGLE_REQUIRE_INVITE, true), + createAuthorizationURL: (state: string, codeVerifier: string) => { + const scopes = ['openid', 'profile', 'email']; + return googleProvider.createAuthorizationURL(state, codeVerifier, scopes); + }, + validateAuthorizationCode: async (code: string, codeVerifier: string) => { + const tokens = await googleProvider.validateAuthorizationCode(code, codeVerifier); + const claims = decodeIdToken(tokens.idToken()); + assertGuard<{ + sub: string; + email: string; + name: string; + }>(claims); + return { + sub: claims.sub, + name: claims.name, + username: claims.email, + }; + }, +}; diff --git a/src/lib/server/oauth-providers/index.ts b/src/lib/server/oauth-providers/index.ts new file mode 100644 index 0000000..98a3dcb --- /dev/null +++ b/src/lib/server/oauth-providers/index.ts @@ -0,0 +1,2 @@ +export { authentik } from './authentik'; +export { google } from './google'; diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts index 5c61aa6..1cf08ef 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/server/oauth.ts @@ -1,15 +1,19 @@ -import { Authentik, Google } from 'arctic'; -import { env } from '$env/dynamic/private'; +import type { AuthProvider } from '$lib/auth'; +import { authentik, google } from '$lib/server/oauth-providers'; -export const authentik = new Authentik( - env.AUTH_DOMAIN, - env.AUTH_CLIENT_ID, - env.AUTH_CLIENT_SECRET, - `${env.ORIGIN}/auth/authentik/callback`, -); +export interface IOAuthClaims { + sub: string; + name: string; + username: string; +} -export const google = new Google( - env.GOOGLE_CLIENT_ID, - env.GOOGLE_CLIENT_SECRET, - `${env.ORIGIN}/auth/google/callback`, -); +export interface IOAuthProvider { + readonly requireInvite: boolean; + createAuthorizationURL(state: string, codeVerifier: string): URL; + validateAuthorizationCode(code: string, codeVerifier: string): Promise; +} + +export const oauthProviders: Record = { + authentik, + google, +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ac680b3..edc0091 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function envToBool(value: string | undefined, defaultValue = false): boolean { + if (typeof value === "undefined") { + return defaultValue; + } + + return ['true', '1', 'yes'].includes(value.toLowerCase()); +} diff --git a/src/routes/auth/+page.server.ts b/src/routes/auth/+page.server.ts index 7f23ea4..850472c 100644 --- a/src/routes/auth/+page.server.ts +++ b/src/routes/auth/+page.server.ts @@ -1,14 +1,14 @@ -import { fail, redirect } from "@sveltejs/kit"; -import { invalidateSession, deleteSessionTokenCookie } from "$lib/server/auth"; -import type { Actions } from "./$types"; +import { fail, redirect } from '@sveltejs/kit'; +import { invalidateSession, deleteSessionTokenCookie } from '$lib/server/auth'; +import type { Actions } from './$types'; export const actions: Actions = { - logout: async (event) => { - if (event.locals.session === null) { + logout: async ({ locals, cookies }) => { + if (locals.session === null) { return fail(401); } - await invalidateSession(event.locals.session.id); - deleteSessionTokenCookie(event); - return redirect(302, "/"); - } + await invalidateSession(locals.session.id); + deleteSessionTokenCookie(cookies); + redirect(302, '/'); + }, }; diff --git a/src/routes/auth/[provider]/+server.ts b/src/routes/auth/[provider]/+server.ts new file mode 100644 index 0000000..6a624b0 --- /dev/null +++ b/src/routes/auth/[provider]/+server.ts @@ -0,0 +1,36 @@ +import { generateCodeVerifier, generateState } from 'arctic'; +import { oauthProviders } from '$lib/server/oauth'; +import { is } from 'typia'; +import { type AuthProvider, enabledAuthProviders } from '$lib/auth'; + +export async function GET({ params: { provider }, url, cookies }) { + if (!is(provider) || !enabledAuthProviders[provider]) { + return new Response(null, { status: 404 }); + } + const oauthProvider = oauthProviders[provider]; + + const inviteToken = url.searchParams.get('invite') ?? ''; + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const authUrl = oauthProvider.createAuthorizationURL(state + inviteToken, codeVerifier); + + cookies.set(`${provider}_oauth_state`, state, { + path: '/', + httpOnly: true, + maxAge: 60 * 10, // 10 minutes + sameSite: 'lax', + }); + cookies.set(`${provider}_code_verifier`, codeVerifier, { + path: '/', + httpOnly: true, + maxAge: 60 * 10, // 10 minutes + sameSite: 'lax', + }); + + return new Response(null, { + status: 302, + headers: { + Location: authUrl.toString(), + }, + }); +} diff --git a/src/routes/auth/[provider]/callback/+page.server.ts b/src/routes/auth/[provider]/callback/+page.server.ts new file mode 100644 index 0000000..435ffd1 --- /dev/null +++ b/src/routes/auth/[provider]/callback/+page.server.ts @@ -0,0 +1,76 @@ +import { is } from 'typia'; +import type { PageServerLoad } from './$types'; +import { error, redirect } from '@sveltejs/kit'; +import { type AuthProvider, enabledAuthProviders } from '$lib/auth'; +import { oauthProviders } from '$lib/server/oauth'; +import { ArcticFetchError, OAuth2RequestError } from 'arctic'; +import { db } from '$lib/server/db'; +import * as table from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createSession, isValidInviteToken, setSessionTokenCookie } from '$lib/server/auth'; + +export const load: PageServerLoad = async ({ params: { provider }, url, cookies }) => { + if (!is(provider) || !enabledAuthProviders[provider]) { + error(404); + } + + const oauthProvider = oauthProviders[provider]; + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = cookies.get(`${provider}_oauth_state`) ?? null; + const codeVerifier = cookies.get(`${provider}_code_verifier`) ?? null; + + if (code === null || state === null || storedState === null || codeVerifier === null) { + error(400, 'Missing url parameters'); + } + + const stateGeneratedToken = state.slice(0, storedState.length); + const stateInviteToken = state.slice(storedState.length); + if (stateGeneratedToken !== storedState) { + error(400, 'Invalid state in url'); + } + + let claims; + try { + claims = await oauthProvider.validateAuthorizationCode(code, codeVerifier); + } catch (e) { + if (e instanceof OAuth2RequestError) { + console.debug('Arctic: OAuth: invalid authorization code, credentials, or redirect URI', e); + error(400, 'Invalid authorization code'); + } + if (e instanceof ArcticFetchError) { + console.debug('Arctic: failed to call `fetch()`', e); + error(400, 'Failed to validate authorization code'); + } + console.error('Unexpected error validating authorization code', code, e); + error(500); + } + + const existingUser = await db.query.users.findFirst({ where: eq(table.users.id, claims.sub) }); + + if (existingUser) { + const session = await createSession(existingUser.id); + setSessionTokenCookie(cookies, session.id, session.expiresAt); + redirect(302, '/'); + } + + if (oauthProvider.requireInvite && !isValidInviteToken(stateInviteToken)) { + const message = + stateInviteToken.length === 0 ? 'sign up with an invite link first' : 'invalid invite link'; + error(403, 'Not Authorized: ' + message); + } + + const user: table.User = { + id: claims.sub, + authSource: provider, + username: claims.username, + name: claims.name, + }; + await db.insert(table.users).values(user); + console.log('created user', user, 'using provider', provider, 'with invite token', stateInviteToken); + + const session = await createSession(user.id); + setSessionTokenCookie(cookies, session.id, session.expiresAt); + + redirect(302, '/'); +}; diff --git a/src/routes/auth/authentik/+server.ts b/src/routes/auth/authentik/+server.ts deleted file mode 100644 index 5810654..0000000 --- a/src/routes/auth/authentik/+server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { generateState, generateCodeVerifier } from "arctic"; -import { authentik } from "$lib/server/oauth"; - -import type { RequestEvent } from "@sveltejs/kit"; - -export async function GET(event: RequestEvent): Promise { - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const url = authentik.createAuthorizationURL(state, codeVerifier, ["openid", "profile"]); - - event.cookies.set("authentik_oauth_state", state, { - path: "/", - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: "lax" - }); - event.cookies.set("authentik_code_verifier", codeVerifier, { - path: "/", - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: "lax" - }); - - return new Response(null, { - status: 302, - headers: { - Location: url.toString() - } - }); -} diff --git a/src/routes/auth/authentik/callback/+server.ts b/src/routes/auth/authentik/callback/+server.ts deleted file mode 100644 index 4a8ffd2..0000000 --- a/src/routes/auth/authentik/callback/+server.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createSession, setSessionTokenCookie } from '$lib/server/auth'; -import { authentik } from '$lib/server/oauth'; -import { decodeIdToken } from 'arctic'; - -import type { RequestEvent } from '@sveltejs/kit'; -import type { OAuth2Tokens } from 'arctic'; -import { db } from '$lib/server/db'; -import { eq } from 'drizzle-orm'; - -import * as table from '$lib/server/db/schema'; - -export async function GET(event: RequestEvent): Promise { - const code = event.url.searchParams.get('code'); - const state = event.url.searchParams.get('state'); - const storedState = event.cookies.get('authentik_oauth_state') ?? null; - const codeVerifier = event.cookies.get('authentik_code_verifier') ?? null; - - if (code === null || state === null || storedState === null || codeVerifier === null) { - return new Response(null, { - status: 400, - }); - } - if (state !== storedState) { - return new Response(null, { - status: 400, - }); - } - - let tokens: OAuth2Tokens; - try { - tokens = await authentik.validateAuthorizationCode(code, codeVerifier); - } catch (e) { - // Invalid code or client credentials - return new Response(null, { - status: 400, - }); - } - const claims = decodeIdToken(tokens.idToken()) as { - sub: string; - preferred_username: string; - name: string; - }; - console.log('claims', claims); - const userId: string = claims.sub; - const username: string = claims.preferred_username; - - 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: '/', - }, - }); - } - - const user: table.User = { - id: userId, - authSource: 'authentik', - username, - name: claims.name as string, - }; - - try { - await db.insert(table.users).values(user); - const session = await createSession(user.id); - setSessionTokenCookie(event, session.id, session.expiresAt); - } catch (e) { - console.error('failed to create user', e); - return new Response(null, { - status: 500, - }); - } - - return new Response(null, { - status: 302, - headers: { - Location: '/', - }, - }); -} diff --git a/src/routes/auth/google/+server.ts b/src/routes/auth/google/+server.ts deleted file mode 100644 index c9c7a46..0000000 --- a/src/routes/auth/google/+server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { RequestHandler } from './$types'; -import { generateCodeVerifier, generateState } from 'arctic'; -import { google } from '$lib/server/oauth'; - -export const GET: RequestHandler = ({ url, cookies }) => { - const inviteToken = url.searchParams.get('invite'); - - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const scopes = ['openid', 'profile', 'email']; - const authUrl = google.createAuthorizationURL(state, codeVerifier, scopes); - - cookies.set('google_oauth_state', state, { - path: '/', - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', - }); - cookies.set('google_code_verifier', codeVerifier, { - path: '/', - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', - }); - if (inviteToken !== null) cookies.set('invite_token', inviteToken, { - path: '/', - httpOnly: true, - maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', - }); - - return new Response(null, { - status: 302, - headers: { - Location: authUrl.toString(), - }, - }); -}; diff --git a/src/routes/auth/google/callback/+server.ts b/src/routes/auth/google/callback/+server.ts deleted file mode 100644 index 12adcd5..0000000 --- a/src/routes/auth/google/callback/+server.ts +++ /dev/null @@ -1,100 +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; - const inviteToken = cookies.get('invite_token') ?? null; - - if (code === null || state === null || storedState === null || codeVerifier === null) { - 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: '/', - }, - }); - } - - // TODO: proper error page - if (inviteToken === null || !isValidInviteToken(inviteToken)) { - return new Response(null, { - status: 400, - }); - } - - 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', inviteToken); - - const session = await createSession(user.id); - - setSessionTokenCookie(event, session.id, session.expiresAt); - - return new Response(null, { - status: 302, - headers: { - Location: '/', - }, - }); -};