From 94514ec965cd110000f211008d25f215bddcee8e Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Fri, 2 May 2025 15:53:21 -0700 Subject: [PATCH] auth: refactor common oauth provider logic, add options to disable providers and require invites --- .env.example | 17 ++-- src/lib/auth.ts | 9 ++ .../components/app/auth-form/auth-form.svelte | 5 ++ src/lib/server/auth.ts | 2 +- src/lib/server/oauth-providers/authentik.ts | 34 ++++++++ src/lib/server/oauth-providers/google.ts | 33 ++++++++ src/lib/server/oauth-providers/index.ts | 2 + src/lib/server/oauth.ts | 30 ++++--- src/lib/utils.ts | 8 ++ src/routes/auth/[provider]/+server.ts | 36 ++++++++ .../callback/+page.server.ts | 64 ++++++-------- src/routes/auth/authentik/+server.ts | 30 ------- src/routes/auth/authentik/callback/+server.ts | 84 ------------------- src/routes/auth/google/+server.ts | 32 ------- 14 files changed, 182 insertions(+), 204 deletions(-) create mode 100644 src/lib/auth.ts create mode 100644 src/lib/server/oauth-providers/authentik.ts create mode 100644 src/lib/server/oauth-providers/google.ts create mode 100644 src/lib/server/oauth-providers/index.ts create mode 100644 src/routes/auth/[provider]/+server.ts rename src/routes/auth/{google => [provider]}/callback/+page.server.ts (58%) delete mode 100644 src/routes/auth/authentik/+server.ts delete mode 100644 src/routes/auth/authentik/callback/+server.ts delete mode 100644 src/routes/auth/google/+server.ts 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/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 7c2ce15..2a19451 100644 --- a/src/lib/components/app/auth-form/auth-form.svelte +++ b/src/lib/components/app/auth-form/auth-form.svelte @@ -3,6 +3,7 @@ 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; @@ -14,6 +15,7 @@
+ {#if enabledAuthProviders.authentik }
submitted = true} action="/auth/authentik{inviteToken ? `?invite=${inviteToken}` : ''}"> @@ -30,6 +32,8 @@ Sign in with Authentik
+ {/if} + {#if enabledAuthProviders.google }
submitted = true} action="/auth/google{inviteToken ? `?invite=${inviteToken}` : ''}"> @@ -46,4 +50,5 @@ Sign in with Google
+ {/if}
diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 5caf886..e1791fa 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -86,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/[provider]/+server.ts b/src/routes/auth/[provider]/+server.ts new file mode 100644 index 0000000..4d196a0 --- /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(event) { + const { provider } = event.params; + if (!is(provider) || !enabledAuthProviders[provider]) { + return new Response(null, { status: 404 }); + } + const oauthProvider = oauthProviders[provider]; + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = oauthProvider.createAuthorizationURL(state, codeVerifier); + + event.cookies.set(`${provider}_oauth_state`, state, { + path: '/', + httpOnly: true, + maxAge: 60 * 10, // 10 minutes + sameSite: 'lax', + }); + event.cookies.set(`${provider}_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/google/callback/+page.server.ts b/src/routes/auth/[provider]/callback/+page.server.ts similarity index 58% rename from src/routes/auth/google/callback/+page.server.ts rename to src/routes/auth/[provider]/callback/+page.server.ts index aad3664..435ffd1 100644 --- a/src/routes/auth/google/callback/+page.server.ts +++ b/src/routes/auth/[provider]/callback/+page.server.ts @@ -1,21 +1,24 @@ -import { createSession, isValidInviteToken, setSessionTokenCookie } from '$lib/server/auth'; +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 { google } from '$lib/server/oauth'; -import { error, redirect } from '@sveltejs/kit'; -import type { OAuth2Tokens } from 'arctic'; -import * as arctic from 'arctic'; import { eq } from 'drizzle-orm'; -import { assertGuard } from 'typia'; -import type { PageServerLoad } from './$types'; +import { createSession, isValidInviteToken, setSessionTokenCookie } from '$lib/server/auth'; -export const load: PageServerLoad = async (event) => { - const { url, cookies } = event; +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('google_oauth_state') ?? null; - const codeVerifier = cookies.get('google_code_verifier') ?? null; + 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'); @@ -27,35 +30,23 @@ export const load: PageServerLoad = async (event) => { error(400, 'Invalid state in url'); } - let tokens: OAuth2Tokens; + let claims; try { - tokens = await google.validateAuthorizationCode(code, codeVerifier); + claims = await oauthProvider.validateAuthorizationCode(code, codeVerifier); } catch (e) { - if (e instanceof arctic.OAuth2RequestError) { + if (e instanceof OAuth2RequestError) { console.debug('Arctic: OAuth: invalid authorization code, credentials, or redirect URI', e); - throw error(400, 'Invalid authorization code'); + error(400, 'Invalid authorization code'); } - if (e instanceof arctic.ArcticFetchError) { + if (e instanceof ArcticFetchError) { console.debug('Arctic: failed to call `fetch()`', e); error(400, 'Failed to validate authorization code'); } - console.error('Unxepcted error validating authorization code', code, e); + console.error('Unexpected error validating authorization code', code, e); error(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) }); + const existingUser = await db.query.users.findFirst({ where: eq(table.users.id, claims.sub) }); if (existingUser) { const session = await createSession(existingUser.id); @@ -63,25 +54,22 @@ export const load: PageServerLoad = async (event) => { redirect(302, '/'); } - if (!isValidInviteToken(stateInviteToken)) { + 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: userId, - authSource: 'google', - username: claims.email, + id: claims.sub, + authSource: provider, + username: claims.username, 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); + 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 8519772..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.cookies, 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.cookies, 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 901a77a..0000000 --- a/src/routes/auth/google/+server.ts +++ /dev/null @@ -1,32 +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 + inviteToken, 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', - }); - - return new Response(null, { - status: 302, - headers: { - Location: authUrl.toString(), - }, - }); -};