mirror of
https://github.com/tale/headplane.git
synced 2026-07-28 08:38:57 +00:00
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import * as oidc from "openid-client";
|
|
import { data, redirect } from "react-router";
|
|
|
|
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
|
import log from "~/utils/log";
|
|
import { createOidcStateCookie } from "~/utils/oidc-state";
|
|
|
|
import type { Route } from "./+types/oidc-callback";
|
|
|
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
|
const oidcConnector = await context.oidc?.connector.get();
|
|
if (!oidcConnector?.isValid) {
|
|
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
if (url.searchParams.toString().length === 0) {
|
|
return redirect("/login?s=error_no_query");
|
|
}
|
|
|
|
const cookie = createOidcStateCookie(context.config);
|
|
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
|
|
|
|
if (oidcCookieState == null) {
|
|
log.warn("auth", "Called OIDC callback without session cookie");
|
|
return redirect("/login?s=error_no_session");
|
|
}
|
|
|
|
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
|
|
if (!state || !nonce || !redirect_uri || !verifier) {
|
|
log.warn("auth", "OIDC session cookie is missing required fields");
|
|
return redirect("/login?s=error_invalid_session");
|
|
}
|
|
|
|
try {
|
|
const callbackUrl = new URL(redirect_uri);
|
|
const currentUrl = new URL(request.url);
|
|
callbackUrl.search = currentUrl.search;
|
|
|
|
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
|
|
expectedState: state,
|
|
expectedNonce: nonce,
|
|
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
|
|
});
|
|
|
|
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(
|
|
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 userId = await context.auth.findOrCreateUser(claims.sub, {
|
|
name,
|
|
email: userInfo.email,
|
|
});
|
|
|
|
try {
|
|
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
|
const hsUsers = await hsApi.getUsers();
|
|
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
|
|
if (hsUser) {
|
|
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
|
}
|
|
} catch (error) {
|
|
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
|
}
|
|
|
|
return redirect("/", {
|
|
headers: {
|
|
"Set-Cookie": await context.auth.createOidcSession(userId, {
|
|
name,
|
|
email: userInfo.email,
|
|
username,
|
|
picture,
|
|
}),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof oidc.ResponseBodyError) {
|
|
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
|
|
|
|
// Check for PKCE-related errors
|
|
if (
|
|
error.error.toLowerCase().includes("code_verifier") ||
|
|
error.error.toLowerCase().includes("code verifier") ||
|
|
error.error.toLowerCase().includes("pkce")
|
|
) {
|
|
log.error(
|
|
"auth",
|
|
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
|
|
oidcConnector.usePKCE,
|
|
);
|
|
|
|
if (!oidcConnector.usePKCE) {
|
|
log.error(
|
|
"auth",
|
|
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
|
|
);
|
|
}
|
|
}
|
|
} else if (error instanceof oidc.AuthorizationResponseError) {
|
|
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
|
|
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
|
|
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
|
|
} else if (error instanceof oidc.ClientError) {
|
|
log.error(
|
|
"auth",
|
|
"Got an OIDC authorization client error: %s",
|
|
error.cause instanceof Error ? error.cause.message : String(error.cause),
|
|
);
|
|
} else {
|
|
log.error(
|
|
"auth",
|
|
"Got an OIDC error: %s",
|
|
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
|
|
);
|
|
}
|
|
return redirect("/login?s=error_auth_failed");
|
|
}
|
|
}
|