mirror of
https://github.com/tale/headplane.git
synced 2026-07-28 08:38:57 +00:00
132 lines
3.4 KiB
TypeScript
132 lines
3.4 KiB
TypeScript
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<LoadContext>) {
|
|
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 === '<none>' ? 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;
|
|
}
|