feat: cleanup oidc logic and surface errors better

This commit is contained in:
Aarnav Tale
2025-12-04 00:44:31 -05:00
parent d363ed5486
commit ba61656fb0
14 changed files with 624 additions and 628 deletions
+96 -78
View File
@@ -1,75 +1,99 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { HeadplaneConfig } from '~/server/config/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;
}
import log from '~/utils/log';
import type { OidcCookieState } from './oidc-start';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc || typeof context.oidc === 'string') {
throw new Error('OIDC is not enabled');
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
return redirect('/login?s=error_no_query');
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const data: OidcFlowSession | null = await cookie.parse(
const oidcCookieState: OidcCookieState | null = await cookie.parse(
request.headers.get('Cookie'),
);
if (data === null) {
console.warn('OIDC flow session not found');
return redirect('/login');
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
const { code_verifier, state, nonce, redirect_uri } = data;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
const { state, nonce } = oidcCookieState;
if (!state || !nonce) {
log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
}
// 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 tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
request,
{
expectedState: state,
expectedNonce: nonce,
},
);
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn('auth', 'No subject found in OIDC claims');
return redirect('/login?s=error_no_sub');
}
const userInfo = await oidc.fetchUserInfo(
context.oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username =
userInfo.preferred_username ?? userInfo.email?.split('@')[0] ?? 'user';
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? 'SSO User'));
const picture =
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: userInfo.picture;
const [{ count: userCount }] = await context.db
.select({ count: count() })
@@ -80,52 +104,46 @@ export async function loader({
.insert(users)
.values({
id: ulid(),
sub: user.subject,
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect('/machines', {
return redirect('/', {
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,
api_key: context.oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} 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;
if (error instanceof oidc.ResponseBodyError) {
log.error(
'auth',
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
}
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`;
}
if (error instanceof oidc.AuthorizationResponseError) {
log.error(
'auth',
'Got an OIDC authorization response error: %s',
error.error,
);
}
return undefined;
if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
}
return redirect('/login?s=error_auth_failed');
}
}