mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
Merge pull request #537 from croatialu/feat/oidc-weak-rsa-fallback
This commit is contained in:
@@ -14,6 +14,23 @@ export const pathSupportedKeys = [
|
||||
"oidc.headscale_api_key",
|
||||
] as const;
|
||||
|
||||
function normalizeStringArray(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0 || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const serverConfig = type({
|
||||
host: 'string.ip = "0.0.0.0"',
|
||||
port: "number.integer = 3000",
|
||||
@@ -98,6 +115,8 @@ const oidcConfig = type({
|
||||
.optional(),
|
||||
disable_api_key_login: "boolean = false",
|
||||
scope: 'string = "openid email profile"',
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
allow_weak_rsa_keys: "boolean = false",
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
extra_params: "Record<string, string>?",
|
||||
|
||||
@@ -120,6 +139,8 @@ const partialOidcConfig = type({
|
||||
redirect_uri: "string.url?",
|
||||
disable_api_key_login: "boolean?",
|
||||
scope: "string?",
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
allow_weak_rsa_keys: "boolean?",
|
||||
extra_params: "Record<string, string>?",
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ const appLoadContext = {
|
||||
: config.oidc.token_endpoint_auth_method,
|
||||
usePkce: config.oidc.use_pkce,
|
||||
scope: config.oidc.scope,
|
||||
subjectClaims: config.oidc.subject_claims,
|
||||
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||
extraParams: config.oidc.extra_params,
|
||||
profilePictureSource: config.oidc.profile_picture_source,
|
||||
}),
|
||||
|
||||
+368
-12
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
|
||||
|
||||
import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose";
|
||||
import type { JWSHeaderParameters, JWTPayload, FlattenedJWSInput } from "jose";
|
||||
@@ -21,6 +21,8 @@ export interface OidcConfig {
|
||||
|
||||
usePkce?: boolean;
|
||||
scope?: string;
|
||||
subjectClaims?: string[];
|
||||
allowWeakRsaKeys?: boolean;
|
||||
extraParams?: Record<string, string>;
|
||||
profilePictureSource?: "oidc" | "gravatar";
|
||||
}
|
||||
@@ -114,6 +116,23 @@ interface TokenErrorResponse {
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface JwksResponse {
|
||||
keys?: Array<JsonWebKey & { kid?: string }>;
|
||||
}
|
||||
|
||||
interface DecodedJwtParts {
|
||||
header: Record<string, unknown>;
|
||||
payload: OidcClaims;
|
||||
signature: Buffer;
|
||||
signingInput: string;
|
||||
}
|
||||
|
||||
interface WeakRsaContext {
|
||||
alg: "RS256" | "RS384" | "RS512";
|
||||
decoded: DecodedJwtParts;
|
||||
candidateKeys: Array<JsonWebKey & { kid?: string }>;
|
||||
}
|
||||
|
||||
export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
let config = Object.freeze({ ...initialConfig });
|
||||
|
||||
@@ -122,6 +141,13 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
let jwks: JwksResolver | undefined;
|
||||
let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined =
|
||||
initialConfig.tokenEndpointAuthMethod;
|
||||
const weakJwksCache = new Map<
|
||||
string,
|
||||
{ expiresAt: number; keys: Array<JsonWebKey & { kid?: string }> }
|
||||
>();
|
||||
let hasWarnedWeakKeyMode = false;
|
||||
|
||||
maybeWarnWeakRsaMode(config);
|
||||
|
||||
function status(): ReturnType<OidcService["status"]> {
|
||||
if (lastError) {
|
||||
@@ -355,6 +381,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
|
||||
const claims = verifyResult.value;
|
||||
const enriched = await enrichWithUserInfo(resolved.value, tokens.access_token, claims);
|
||||
|
||||
if (!resolveSubject(enriched)) {
|
||||
return err({
|
||||
code: "missing_sub",
|
||||
message: "ID token and userinfo response are missing all configured subject claims",
|
||||
hint: `Your identity provider did not return a stable user identifier. Configure oidc.subject_claims or ensure one of these claims is present: ${getSubjectClaimOrder().join(", ")}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(buildIdentity(enriched));
|
||||
}
|
||||
|
||||
@@ -523,14 +558,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
clockTolerance: 60,
|
||||
});
|
||||
|
||||
if (!payload.sub) {
|
||||
return err({
|
||||
code: "missing_sub",
|
||||
message: "ID token is missing the 'sub' claim",
|
||||
hint: "Your identity provider did not return a user identifier. Check that your OIDC client is configured to include the 'sub' claim.",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.nonce !== expectedNonce) {
|
||||
return err({
|
||||
code: "nonce_mismatch",
|
||||
@@ -555,6 +582,28 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
});
|
||||
}
|
||||
|
||||
let weakRsaContext: WeakRsaContext | undefined;
|
||||
try {
|
||||
weakRsaContext = await getWeakRsaContext(idToken);
|
||||
} catch (weakRsaCause) {
|
||||
return err({
|
||||
code: "invalid_id_token",
|
||||
message: `ID token verification failed: ${weakRsaCause instanceof Error ? weakRsaCause.message : String(weakRsaCause)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (weakRsaContext) {
|
||||
if (!config.allowWeakRsaKeys) {
|
||||
return err({
|
||||
code: "invalid_id_token",
|
||||
message: "ID token was signed with a weak RSA key that Headplane rejects by default",
|
||||
hint: "If your provider cannot rotate to a 2048-bit-or-larger RSA signing key, set oidc.allow_weak_rsa_keys to true as a temporary compatibility fallback.",
|
||||
});
|
||||
}
|
||||
|
||||
return verifyIdTokenWithWeakRsa(weakRsaContext, expectedNonce);
|
||||
}
|
||||
|
||||
if (cause instanceof joseErrors.JWSSignatureVerificationFailed) {
|
||||
return err({
|
||||
code: "invalid_id_token",
|
||||
@@ -570,13 +619,76 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyIdTokenWithWeakRsa(
|
||||
weakRsaContext: WeakRsaContext,
|
||||
expectedNonce: string,
|
||||
): Promise<Result<OidcClaims, OidcError>> {
|
||||
for (const jwk of weakRsaContext.candidateKeys) {
|
||||
try {
|
||||
const key = createPublicKey({ key: jwk, format: "jwk" });
|
||||
const isValid = verify(
|
||||
getNodeVerifyAlgorithm(weakRsaContext.alg),
|
||||
Buffer.from(weakRsaContext.decoded.signingInput),
|
||||
key,
|
||||
weakRsaContext.decoded.signature,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
continue;
|
||||
}
|
||||
} catch (cause) {
|
||||
return err({
|
||||
code: "invalid_id_token",
|
||||
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasWarnedWeakKeyMode) {
|
||||
hasWarnedWeakKeyMode = true;
|
||||
log.warn(
|
||||
"auth",
|
||||
"OIDC issuer %s is using a weak RSA signing key. Accepting it only because oidc.allow_weak_rsa_keys=true.",
|
||||
config.issuer,
|
||||
);
|
||||
}
|
||||
|
||||
const claimError = validateOidcClaims(
|
||||
weakRsaContext.decoded.payload,
|
||||
config.issuer,
|
||||
config.clientId,
|
||||
60,
|
||||
);
|
||||
if (claimError) {
|
||||
return err(claimError);
|
||||
}
|
||||
|
||||
if (weakRsaContext.decoded.payload.nonce !== expectedNonce) {
|
||||
return err({
|
||||
code: "nonce_mismatch",
|
||||
message: `Nonce mismatch: expected ${expectedNonce}, got ${weakRsaContext.decoded.payload.nonce}`,
|
||||
hint: "Please try signing in again. This can happen with stale browser sessions.",
|
||||
});
|
||||
}
|
||||
|
||||
return ok(weakRsaContext.decoded.payload);
|
||||
}
|
||||
|
||||
return err({
|
||||
code: "invalid_id_token",
|
||||
message: "ID token signature verification failed",
|
||||
hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
|
||||
});
|
||||
}
|
||||
|
||||
async function enrichWithUserInfo(
|
||||
ep: ResolvedEndpoints,
|
||||
accessToken: string,
|
||||
claims: OidcClaims,
|
||||
): Promise<OidcClaims> {
|
||||
const needsEnrichment = !claims.name && !claims.email && !claims.picture;
|
||||
if (!needsEnrichment || !ep.userinfoEndpoint) {
|
||||
const needsEnrichment =
|
||||
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
|
||||
const needsSubjectEnrichment = !resolveSubject(claims);
|
||||
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) {
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -595,8 +707,17 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
}
|
||||
|
||||
const userInfo = (await response.json()) as Record<string, unknown>;
|
||||
const subjectClaimValues = Object.fromEntries(
|
||||
getSubjectClaimOrder()
|
||||
.filter((claim) => claim !== "sub")
|
||||
.map((claim) => [
|
||||
claim,
|
||||
readClaimAsString(claims, claim) ?? readClaimAsString(userInfo, claim),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...claims,
|
||||
...subjectClaimValues,
|
||||
name: claims.name ?? (userInfo.name as string | undefined),
|
||||
given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
|
||||
family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
|
||||
@@ -604,6 +725,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
claims.preferred_username ?? (userInfo.preferred_username as string | undefined),
|
||||
email: claims.email ?? (userInfo.email as string | undefined),
|
||||
picture: claims.picture ?? (userInfo.picture as string | undefined),
|
||||
sub: claims.sub ?? readClaimAsString(userInfo, "sub"),
|
||||
};
|
||||
} catch (cause) {
|
||||
log.debug(
|
||||
@@ -617,6 +739,11 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
}
|
||||
|
||||
function buildIdentity(claims: OidcClaims): OidcIdentity {
|
||||
const subject = resolveSubject(claims);
|
||||
if (!subject) {
|
||||
throw new Error("OIDC subject was not resolved before identity construction");
|
||||
}
|
||||
|
||||
const name =
|
||||
claims.name ??
|
||||
(claims.given_name && claims.family_name
|
||||
@@ -637,7 +764,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
|
||||
return {
|
||||
issuer: config.issuer,
|
||||
subject: claims.sub!,
|
||||
subject,
|
||||
name,
|
||||
username,
|
||||
email: claims.email,
|
||||
@@ -654,10 +781,104 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
|
||||
function reload(newConfig: OidcConfig): void {
|
||||
config = Object.freeze({ ...newConfig });
|
||||
maybeWarnWeakRsaMode(config);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
function getSubjectClaimOrder(): string[] {
|
||||
return [
|
||||
"sub",
|
||||
...normalizeSubjectClaims(config.subjectClaims).filter((claim) => claim !== "sub"),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveSubject(claims: OidcClaims): string | undefined {
|
||||
for (const claim of getSubjectClaimOrder()) {
|
||||
const value = readClaimAsString(claims, claim);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { status, discover, startFlow, handleCallback, invalidate, reload };
|
||||
|
||||
function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void {
|
||||
if (!currentConfig.allowWeakRsaKeys) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"auth",
|
||||
"OIDC weak RSA compatibility mode is enabled for issuer %s. This lowers token verification security and should only be used as a temporary workaround.",
|
||||
currentConfig.issuer,
|
||||
);
|
||||
}
|
||||
|
||||
async function getWeakRsaContext(idToken: string): Promise<WeakRsaContext | undefined> {
|
||||
if (!endpoints?.jwksUri) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const decoded = decodeJwtParts(idToken);
|
||||
const alg = decoded.header.alg;
|
||||
if (alg !== "RS256" && alg !== "RS384" && alg !== "RS512") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys = await fetchSigningJwks(endpoints.jwksUri);
|
||||
const candidateKeys = selectCandidateSigningKeys(keys, decoded.header.kid).filter((jwk) =>
|
||||
isWeakRsaKey(jwk),
|
||||
);
|
||||
|
||||
if (candidateKeys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { alg, decoded, candidateKeys };
|
||||
}
|
||||
|
||||
async function fetchSigningJwks(jwksUri: string): Promise<Array<JsonWebKey & { kid?: string }>> {
|
||||
const cached = weakJwksCache.get(jwksUri);
|
||||
const now = Date.now();
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.keys;
|
||||
}
|
||||
|
||||
const response = await fetch(jwksUri, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`JWKS endpoint returned ${response.status}: ${jwksUri}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as JwksResponse;
|
||||
const keys = Array.isArray(json.keys) ? json.keys : [];
|
||||
if (keys.length === 0) {
|
||||
throw new Error("JWKS response did not contain any keys");
|
||||
}
|
||||
|
||||
weakJwksCache.set(jwksUri, {
|
||||
expiresAt: now + 60_000,
|
||||
keys,
|
||||
});
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
function readClaimAsString(claims: Record<string, unknown>, claimName: string): string | undefined {
|
||||
const value = claims[claimName];
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function generateRandom(bytes = 32): string {
|
||||
@@ -667,3 +888,138 @@ function generateRandom(bytes = 32): string {
|
||||
function computeS256Challenge(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
function decodeJwtParts(token: string): DecodedJwtParts {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("JWT must have exactly 3 parts");
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
||||
const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(encodedPayload, "base64url").toString("utf8"),
|
||||
) as OidcClaims;
|
||||
const signature = Buffer.from(encodedSignature, "base64url");
|
||||
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
signature,
|
||||
signingInput: `${encodedHeader}.${encodedPayload}`,
|
||||
};
|
||||
}
|
||||
|
||||
function selectCandidateSigningKeys(
|
||||
keys: Array<JsonWebKey & { kid?: string }>,
|
||||
expectedKid: unknown,
|
||||
): Array<JsonWebKey & { kid?: string }> {
|
||||
const kid = typeof expectedKid === "string" ? expectedKid : undefined;
|
||||
const rsaKeys = keys.filter((key) => key.kty === "RSA");
|
||||
if (kid) {
|
||||
const matchingKey = rsaKeys.find((key) => key.kid === kid);
|
||||
if (matchingKey) {
|
||||
return [matchingKey];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return rsaKeys;
|
||||
}
|
||||
|
||||
function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" {
|
||||
switch (alg) {
|
||||
case "RS256":
|
||||
return "RSA-SHA256";
|
||||
case "RS384":
|
||||
return "RSA-SHA384";
|
||||
case "RS512":
|
||||
return "RSA-SHA512";
|
||||
default:
|
||||
throw new Error(`Unsupported RSA verification algorithm: ${alg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isWeakRsaKey(jwk: JsonWebKey): boolean {
|
||||
if (jwk.kty !== "RSA" || typeof jwk.n !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getRsaModulusBitLength(jwk.n) < 2048;
|
||||
}
|
||||
|
||||
function getRsaModulusBitLength(base64UrlModulus: string): number {
|
||||
const modulus = Buffer.from(base64UrlModulus, "base64url");
|
||||
if (modulus.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let leadingZeroBits = 0;
|
||||
let currentByte = modulus[0];
|
||||
while ((currentByte & 0x80) === 0 && leadingZeroBits < 8) {
|
||||
leadingZeroBits++;
|
||||
currentByte <<= 1;
|
||||
}
|
||||
|
||||
return modulus.length * 8 - leadingZeroBits;
|
||||
}
|
||||
|
||||
function normalizeSubjectClaims(subjectClaims?: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const claim of subjectClaims ?? []) {
|
||||
const trimmed = claim.trim();
|
||||
if (trimmed.length === 0 || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateOidcClaims(
|
||||
payload: OidcClaims,
|
||||
expectedIssuer: string,
|
||||
expectedAudience: string,
|
||||
clockToleranceSeconds: number,
|
||||
): OidcError | undefined {
|
||||
if (payload.iss !== expectedIssuer) {
|
||||
return {
|
||||
code: "invalid_id_token",
|
||||
message: 'JWT claim validation failed: iss — unexpected "iss" claim value',
|
||||
};
|
||||
}
|
||||
|
||||
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
if (!audiences.includes(expectedAudience)) {
|
||||
return {
|
||||
code: "invalid_id_token",
|
||||
message: 'JWT claim validation failed: aud — unexpected "aud" claim value',
|
||||
};
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (typeof payload.exp === "number" && now - clockToleranceSeconds >= payload.exp) {
|
||||
return {
|
||||
code: "invalid_id_token",
|
||||
message: "ID token is expired",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof payload.nbf === "number" && now + clockToleranceSeconds < payload.nbf) {
|
||||
return {
|
||||
code: "invalid_id_token",
|
||||
message: "JWT claim validation failed: nbf — token is not active yet",
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -217,6 +217,18 @@ integration:
|
||||
# The scopes to request when authenticating users. The default is below.
|
||||
# scope: "openid email profile"
|
||||
|
||||
# Optional fallback claims to use when your provider does not return a standard
|
||||
# OIDC `sub` claim. Headplane always checks `sub` first, then each claim here
|
||||
# in order. For Feishu/Lark, `["open_id", "email"]` is a reasonable fallback.
|
||||
# subject_claims:
|
||||
# - "open_id"
|
||||
# - "email"
|
||||
|
||||
# Allow ID token verification with legacy RSA keys smaller than 2048 bits.
|
||||
# This is disabled by default because it lowers token verification security and
|
||||
# should only be used as a temporary compatibility workaround.
|
||||
# allow_weak_rsa_keys: false
|
||||
|
||||
# Extra query parameters can be passed to the authorization endpoint
|
||||
# by setting them here. This is useful for providers that require any kind
|
||||
# of custom hinting.
|
||||
|
||||
+42
-5
@@ -70,6 +70,8 @@ oidc:
|
||||
# token_endpoint: ""
|
||||
# userinfo_endpoint: ""
|
||||
# scope: "openid email profile"
|
||||
# subject_claims: ["open_id", "email"]
|
||||
# allow_weak_rsa_keys: false
|
||||
# extra_params:
|
||||
# foo: "bar"
|
||||
```
|
||||
@@ -78,6 +80,40 @@ Headplane automatically discovers OIDC endpoints from your issuer's
|
||||
`/.well-known/openid-configuration`. If your IdP does not support discovery,
|
||||
you'll need to set the endpoints manually.
|
||||
|
||||
### Non-standard Subject Claims
|
||||
|
||||
Some providers do not return the standard OIDC `sub` claim in the ID token.
|
||||
Headplane always uses `sub` first, but you can configure fallback claims with
|
||||
`oidc.subject_claims`.
|
||||
|
||||
For Feishu/Lark, the recommended configuration is:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
subject_claims: ["open_id", "email"]
|
||||
```
|
||||
|
||||
This keeps identity matching stable by preferring `open_id` and only falling
|
||||
back to `email` if needed.
|
||||
|
||||
### Legacy Weak RSA Signing Keys
|
||||
|
||||
Some legacy providers still sign ID tokens with RSA keys smaller than 2048
|
||||
bits. Headplane rejects those keys by default.
|
||||
|
||||
If your provider cannot rotate to a stronger signing key yet, you can
|
||||
explicitly enable the compatibility fallback:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
allow_weak_rsa_keys: true
|
||||
```
|
||||
|
||||
::: warning
|
||||
This weakens ID token verification security and should only be used as a
|
||||
temporary workaround while your provider rotates to a 2048-bit-or-larger key.
|
||||
:::
|
||||
|
||||
### PKCE
|
||||
|
||||
::: warning
|
||||
@@ -107,8 +143,9 @@ Headplane uses a two-step matching strategy:
|
||||
|
||||
1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
|
||||
each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane
|
||||
extracts the last path segment and compares it to the `sub` claim from the
|
||||
OIDC token. If they match, the user is linked.
|
||||
extracts the last path segment and compares it to the resolved OIDC subject.
|
||||
The resolved subject uses `sub` first, then falls back to any configured
|
||||
`oidc.subject_claims`. If they match, the user is linked.
|
||||
|
||||
2. **Email match (fallback)**: If the subject doesn't match, Headplane falls
|
||||
back to comparing the user's email address from the OIDC `userinfo` endpoint
|
||||
@@ -217,9 +254,9 @@ flow can be skipped. Once completed, users are taken to the main dashboard.
|
||||
- **Invalid API Key**: The `headscale.api_key` may have expired. Generate
|
||||
a new one with `headscale apikeys create --expiration 999d`.
|
||||
|
||||
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the
|
||||
ID token. This is required by the OIDC spec but some providers need explicit
|
||||
configuration.
|
||||
- **Missing the `sub` claim**: If your IdP omits `sub`, configure
|
||||
`oidc.subject_claims` with a stable fallback such as `open_id`. Only use
|
||||
`email` as a fallback when it is stable for your users.
|
||||
|
||||
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
|
||||
matches `{server.base_url}/admin/oidc/callback` exactly.
|
||||
|
||||
@@ -112,6 +112,77 @@ describe("Configuration YAML file loading", () => {
|
||||
expect(config.oidc?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("oidc.subject_claims can be configured from YAML", async () => {
|
||||
const filePath = "/config/oidc-subject-claims.yaml";
|
||||
writeYaml(filePath, {
|
||||
headscale: { url: "http://localhost:8080" },
|
||||
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
|
||||
oidc: {
|
||||
issuer: "https://accounts.google.com",
|
||||
client_id: "my-client-id",
|
||||
client_secret: "my-client-secret",
|
||||
headscale_api_key: "my-api-key",
|
||||
subject_claims: ["open_id", "email"],
|
||||
},
|
||||
});
|
||||
|
||||
const config = await loadConfig(filePath);
|
||||
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]);
|
||||
});
|
||||
|
||||
test("oidc.subject_claims are trimmed, deduplicated, and drop empty values", async () => {
|
||||
const filePath = "/config/oidc-subject-claims-normalized.yaml";
|
||||
writeYaml(filePath, {
|
||||
headscale: { url: "http://localhost:8080" },
|
||||
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
|
||||
oidc: {
|
||||
issuer: "https://accounts.google.com",
|
||||
client_id: "my-client-id",
|
||||
client_secret: "my-client-secret",
|
||||
headscale_api_key: "my-api-key",
|
||||
subject_claims: [" open_id ", "", "email", "open_id", " "],
|
||||
},
|
||||
});
|
||||
|
||||
const config = await loadConfig(filePath);
|
||||
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]);
|
||||
});
|
||||
|
||||
test("oidc.allow_weak_rsa_keys defaults to false", async () => {
|
||||
const filePath = "/config/oidc-weak-rsa-default.yaml";
|
||||
writeYaml(filePath, {
|
||||
headscale: { url: "http://localhost:8080" },
|
||||
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
|
||||
oidc: {
|
||||
issuer: "https://accounts.google.com",
|
||||
client_id: "my-client-id",
|
||||
client_secret: "my-client-secret",
|
||||
headscale_api_key: "my-api-key",
|
||||
},
|
||||
});
|
||||
|
||||
const config = await loadConfig(filePath);
|
||||
expect(config.oidc?.allow_weak_rsa_keys).toBe(false);
|
||||
});
|
||||
|
||||
test("oidc.allow_weak_rsa_keys can be enabled from YAML", async () => {
|
||||
const filePath = "/config/oidc-weak-rsa-enabled.yaml";
|
||||
writeYaml(filePath, {
|
||||
headscale: { url: "http://localhost:8080" },
|
||||
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
|
||||
oidc: {
|
||||
issuer: "https://accounts.google.com",
|
||||
client_id: "my-client-id",
|
||||
client_secret: "my-client-secret",
|
||||
headscale_api_key: "my-api-key",
|
||||
allow_weak_rsa_keys: true,
|
||||
},
|
||||
});
|
||||
|
||||
const config = await loadConfig(filePath);
|
||||
expect(config.oidc?.allow_weak_rsa_keys).toBe(true);
|
||||
});
|
||||
|
||||
test("partial oidc config with enabled field can be parsed", async () => {
|
||||
const filePath = "/config/oidc-partial.yaml";
|
||||
writeYaml(filePath, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { generateKeyPairSync, sign, type KeyObject } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
|
||||
import { SignJWT, exportJWK, generateKeyPair } from "jose";
|
||||
@@ -13,6 +14,8 @@ let server: Server;
|
||||
let baseUrl: string;
|
||||
let privateKey: CryptoKey;
|
||||
let publicJwk: Record<string, unknown>;
|
||||
let weakPrivateKey: KeyObject;
|
||||
let weakPublicJwk: Record<string, unknown>;
|
||||
|
||||
const CLIENT_ID = "test-client";
|
||||
const CLIENT_SECRET = "test-secret";
|
||||
@@ -20,7 +23,11 @@ const CLIENT_SECRET = "test-secret";
|
||||
let tokenHandler: (req: IncomingMessage, res: ServerResponse) => void;
|
||||
let userinfoHandler: ((req: IncomingMessage, res: ServerResponse) => void) | undefined;
|
||||
|
||||
async function signIdToken(claims: Record<string, unknown>, nonce?: string) {
|
||||
async function signIdToken(
|
||||
claims: Record<string, unknown>,
|
||||
nonce?: string,
|
||||
signingKey: CryptoKey | KeyObject = privateKey,
|
||||
) {
|
||||
const jwt = new SignJWT({ nonce, ...claims })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(baseUrl)
|
||||
@@ -28,7 +35,31 @@ async function signIdToken(claims: Record<string, unknown>, nonce?: string) {
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m");
|
||||
|
||||
return jwt.sign(privateKey);
|
||||
return jwt.sign(signingKey);
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string | Buffer) {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
function signWeakRs256IdToken(
|
||||
claims: Record<string, unknown>,
|
||||
nonce?: string,
|
||||
options?: { kid?: string },
|
||||
) {
|
||||
const header = { alg: "RS256", kid: options?.kid ?? "test-key", typ: "JWT" };
|
||||
const payload = {
|
||||
nonce,
|
||||
...claims,
|
||||
iss: baseUrl,
|
||||
aud: CLIENT_ID,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
};
|
||||
|
||||
const signingInput = `${encodeBase64Url(JSON.stringify(header))}.${encodeBase64Url(JSON.stringify(payload))}`;
|
||||
const signature = sign("RSA-SHA256", Buffer.from(signingInput), weakPrivateKey);
|
||||
return `${signingInput}.${encodeBase64Url(signature)}`;
|
||||
}
|
||||
|
||||
// You would think this is a lot better in 2026, but no
|
||||
@@ -49,6 +80,14 @@ beforeAll(async () => {
|
||||
const exported = await exportJWK(keyPair.publicKey);
|
||||
publicJwk = { ...exported, kid: "test-key", use: "sig", alg: "RS256" };
|
||||
|
||||
const weakKeyPair = generateKeyPairSync("rsa", {
|
||||
modulusLength: 1024,
|
||||
publicExponent: 0x10001,
|
||||
});
|
||||
weakPrivateKey = weakKeyPair.privateKey;
|
||||
const weakExported = await exportJWK(weakKeyPair.publicKey);
|
||||
weakPublicJwk = { ...weakExported, kid: "test-key", use: "sig", alg: "RS256" };
|
||||
|
||||
server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
|
||||
@@ -509,6 +548,217 @@ describe("handleCallback", () => {
|
||||
expect(result.error.code).toBe("missing_sub");
|
||||
});
|
||||
|
||||
test("rejects RS256 id tokens signed with 1024-bit RSA keys by default", async () => {
|
||||
const originalPublicJwk = publicJwk;
|
||||
publicJwk = weakPublicJwk;
|
||||
|
||||
try {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = signWeakRs256IdToken({ sub: "weak-key-user" }, flowState.nonce);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("invalid_id_token");
|
||||
expect(result.error.hint).toContain("allow_weak_rsa_keys");
|
||||
} finally {
|
||||
publicJwk = originalPublicJwk;
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts RS256 id tokens signed with 1024-bit RSA keys when explicitly enabled", async () => {
|
||||
const originalPublicJwk = publicJwk;
|
||||
publicJwk = weakPublicJwk;
|
||||
|
||||
try {
|
||||
const svc = createOidcService(testConfig({ usePkce: false, allowWeakRsaKeys: true }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = signWeakRs256IdToken({ sub: "weak-key-user" }, flowState.nonce);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.subject).toBe("weak-key-user");
|
||||
} finally {
|
||||
publicJwk = originalPublicJwk;
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects weak RSA fallback when token kid does not match any JWKS key", async () => {
|
||||
const originalPublicJwk = publicJwk;
|
||||
publicJwk = weakPublicJwk;
|
||||
|
||||
try {
|
||||
const svc = createOidcService(testConfig({ usePkce: false, allowWeakRsaKeys: true }));
|
||||
await svc.discover();
|
||||
svc.reload({
|
||||
...testConfig({
|
||||
usePkce: false,
|
||||
allowWeakRsaKeys: true,
|
||||
authorizationEndpoint: `${baseUrl}/authorize`,
|
||||
tokenEndpoint: `${baseUrl}/token`,
|
||||
jwksUri: `${baseUrl}/jwks`,
|
||||
}),
|
||||
});
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = signWeakRs256IdToken({ sub: "weak-key-user" }, flowState.nonce, {
|
||||
kid: "missing-key",
|
||||
});
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("invalid_id_token");
|
||||
expect(result.error.message).toContain("no applicable key found");
|
||||
} finally {
|
||||
publicJwk = originalPublicJwk;
|
||||
}
|
||||
});
|
||||
|
||||
test("uses configured open_id fallback when sub claim is missing", async () => {
|
||||
const svc = createOidcService(
|
||||
testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }),
|
||||
);
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken(
|
||||
{ open_id: "feishu-open-id", name: "Feishu User" },
|
||||
flowState.nonce,
|
||||
);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.subject).toBe("feishu-open-id");
|
||||
});
|
||||
|
||||
test("uses configured fallback claim from userinfo when id token is missing subject", async () => {
|
||||
const svc = createOidcService(
|
||||
testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }),
|
||||
);
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ name: "Feishu User" }, flowState.nonce);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ open_id: "userinfo-open-id", email: "user@example.com" }));
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.subject).toBe("userinfo-open-id");
|
||||
});
|
||||
|
||||
test("invalid_client triggers auth method retry", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
|
||||
Reference in New Issue
Block a user