import { createHash } from 'node:crypto'; import { count, eq } from 'drizzle-orm'; import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router'; import { ulid } from 'ulidx'; import type { LoadContext } from '~/server'; import { HeadplaneConfig } from '~/server/config/schema'; import { users } from '~/server/db/schema'; import { Roles } from '~/server/web/roles'; import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc'; import { send } from '~/utils/res'; interface OidcFlowSession { state: string; nonce: string; code_verifier: string; redirect_uri: string; } export async function loader({ request, context, }: LoaderFunctionArgs) { if (!context.oidc) { throw new Error('OIDC is not enabled'); } // Check if we have 0 query parameters const url = new URL(request.url); if (url.searchParams.toString().length === 0) { return redirect('/login'); } const cookie = createCookie('__oidc_auth_flow', { httpOnly: true, maxAge: 300, // 5 minutes }); const data: OidcFlowSession | null = await cookie.parse( request.headers.get('Cookie'), ); if (data === null) { console.warn('OIDC flow session not found'); return redirect('/login'); } const { code_verifier, state, nonce, redirect_uri } = data; if (!code_verifier || !state || !nonce || !redirect_uri) { return send({ error: 'Missing OIDC state' }, { status: 400 }); } // Reconstruct the redirect URI using the query parameters // and the one we saved in the session const flowRedirectUri = new URL(redirect_uri); flowRedirectUri.search = url.search; const flowOptions = { redirect_uri: flowRedirectUri.toString(), code_verifier, state, nonce: nonce === '' ? undefined : nonce, }; try { let user = await finishAuthFlow(context.oidc, flowOptions); user = { ...user, picture: setOidcPictureForSource( user, context.config.oidc?.profile_picture_source ?? 'oidc', ), }; const [{ count: userCount }] = await context.db .select({ count: count() }) .from(users) .where(eq(users.caps, Roles.owner)); await context.db .insert(users) .values({ id: ulid(), sub: user.subject, caps: userCount === 0 ? Roles.owner : Roles.member, }) .onConflictDoNothing(); return redirect('/machines', { headers: { 'Set-Cookie': await context.sessions.createSession({ // TODO: This is breaking, to stop the "over-generation" of API // keys because they are currently non-deletable in the headscale // database. Look at this in the future once we have a solution // or we have permissioned API keys. api_key: context.config.oidc?.headscale_api_key!, user, }), }, }); } catch (error) { return new Response(JSON.stringify(formatError(error)), { status: 500, headers: { 'Content-Type': 'application/json', }, }); } } type PictureSource = NonNullable< HeadplaneConfig['oidc'] >['profile_picture_source']; function setOidcPictureForSource(user: FlowUser, source: PictureSource) { // Already set by default in the callback, so we can just return it if (source === 'oidc') { return user.picture; } if (source === 'gravatar') { if (!user.email) { return undefined; } const emailHash = user.email.trim().toLowerCase(); const hash = createHash('sha256').update(emailHash).digest('hex'); return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; } return undefined; }