mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 17:28:14 +00:00
fix: harden OIDC weak RSA fallback
This commit is contained in:
@@ -14,6 +14,23 @@ export const pathSupportedKeys = [
|
|||||||
"oidc.headscale_api_key",
|
"oidc.headscale_api_key",
|
||||||
] as const;
|
] 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({
|
const serverConfig = type({
|
||||||
host: 'string.ip = "0.0.0.0"',
|
host: 'string.ip = "0.0.0.0"',
|
||||||
port: "number.integer = 3000",
|
port: "number.integer = 3000",
|
||||||
@@ -98,7 +115,8 @@ const oidcConfig = type({
|
|||||||
.optional(),
|
.optional(),
|
||||||
disable_api_key_login: "boolean = false",
|
disable_api_key_login: "boolean = false",
|
||||||
scope: 'string = "openid email profile"',
|
scope: 'string = "openid email profile"',
|
||||||
subject_claims: "string[]?",
|
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||||
|
allow_weak_rsa_keys: "boolean = false",
|
||||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||||
extra_params: "Record<string, string>?",
|
extra_params: "Record<string, string>?",
|
||||||
|
|
||||||
@@ -121,7 +139,8 @@ const partialOidcConfig = type({
|
|||||||
redirect_uri: "string.url?",
|
redirect_uri: "string.url?",
|
||||||
disable_api_key_login: "boolean?",
|
disable_api_key_login: "boolean?",
|
||||||
scope: "string?",
|
scope: "string?",
|
||||||
subject_claims: "string[]?",
|
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||||
|
allow_weak_rsa_keys: "boolean?",
|
||||||
extra_params: "Record<string, string>?",
|
extra_params: "Record<string, string>?",
|
||||||
profile_picture_source: '"oidc" | "gravatar"?',
|
profile_picture_source: '"oidc" | "gravatar"?',
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ const appLoadContext = {
|
|||||||
usePkce: config.oidc.use_pkce,
|
usePkce: config.oidc.use_pkce,
|
||||||
scope: config.oidc.scope,
|
scope: config.oidc.scope,
|
||||||
subjectClaims: config.oidc.subject_claims,
|
subjectClaims: config.oidc.subject_claims,
|
||||||
|
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||||
extraParams: config.oidc.extra_params,
|
extraParams: config.oidc.extra_params,
|
||||||
profilePictureSource: config.oidc.profile_picture_source,
|
profilePictureSource: config.oidc.profile_picture_source,
|
||||||
}),
|
}),
|
||||||
|
|||||||
+216
-113
@@ -22,6 +22,7 @@ export interface OidcConfig {
|
|||||||
usePkce?: boolean;
|
usePkce?: boolean;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
subjectClaims?: string[];
|
subjectClaims?: string[];
|
||||||
|
allowWeakRsaKeys?: boolean;
|
||||||
extraParams?: Record<string, string>;
|
extraParams?: Record<string, string>;
|
||||||
profilePictureSource?: "oidc" | "gravatar";
|
profilePictureSource?: "oidc" | "gravatar";
|
||||||
}
|
}
|
||||||
@@ -119,6 +120,19 @@ interface JwksResponse {
|
|||||||
keys?: Array<JsonWebKey & { kid?: string }>;
|
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 {
|
export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||||
let config = Object.freeze({ ...initialConfig });
|
let config = Object.freeze({ ...initialConfig });
|
||||||
|
|
||||||
@@ -127,6 +141,13 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
let jwks: JwksResolver | undefined;
|
let jwks: JwksResolver | undefined;
|
||||||
let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined =
|
let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined =
|
||||||
initialConfig.tokenEndpointAuthMethod;
|
initialConfig.tokenEndpointAuthMethod;
|
||||||
|
const weakJwksCache = new Map<
|
||||||
|
string,
|
||||||
|
{ expiresAt: number; keys: Array<JsonWebKey & { kid?: string }> }
|
||||||
|
>();
|
||||||
|
let hasWarnedWeakKeyMode = false;
|
||||||
|
|
||||||
|
maybeWarnWeakRsaMode(config);
|
||||||
|
|
||||||
function status(): ReturnType<OidcService["status"]> {
|
function status(): ReturnType<OidcService["status"]> {
|
||||||
if (lastError) {
|
if (lastError) {
|
||||||
@@ -547,10 +568,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
|
|
||||||
return ok(payload);
|
return ok(payload);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (isWeakRsaJoseError(cause)) {
|
|
||||||
return verifyIdTokenWithWeakRsa(idToken, expectedNonce);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cause instanceof joseErrors.JWTClaimValidationFailed) {
|
if (cause instanceof joseErrors.JWTClaimValidationFailed) {
|
||||||
return err({
|
return err({
|
||||||
code: "invalid_id_token",
|
code: "invalid_id_token",
|
||||||
@@ -565,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) {
|
if (cause instanceof joseErrors.JWSSignatureVerificationFailed) {
|
||||||
return err({
|
return err({
|
||||||
code: "invalid_id_token",
|
code: "invalid_id_token",
|
||||||
@@ -581,81 +620,64 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function verifyIdTokenWithWeakRsa(
|
async function verifyIdTokenWithWeakRsa(
|
||||||
idToken: string,
|
weakRsaContext: WeakRsaContext,
|
||||||
expectedNonce: string,
|
expectedNonce: string,
|
||||||
): Promise<Result<OidcClaims, OidcError>> {
|
): Promise<Result<OidcClaims, OidcError>> {
|
||||||
if (!endpoints?.jwksUri) {
|
for (const jwk of weakRsaContext.candidateKeys) {
|
||||||
return err({
|
try {
|
||||||
code: "invalid_id_token",
|
const key = createPublicKey({ key: jwk, format: "jwk" });
|
||||||
message: "JWKS URI is not available for weak RSA verification fallback",
|
const isValid = verify(
|
||||||
});
|
getNodeVerifyAlgorithm(weakRsaContext.alg),
|
||||||
}
|
Buffer.from(weakRsaContext.decoded.signingInput),
|
||||||
|
key,
|
||||||
|
weakRsaContext.decoded.signature,
|
||||||
|
);
|
||||||
|
|
||||||
let decoded: ReturnType<typeof decodeJwtParts>;
|
if (!isValid) {
|
||||||
try {
|
continue;
|
||||||
decoded = decodeJwtParts(idToken);
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return err({
|
|
||||||
code: "invalid_id_token",
|
|
||||||
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const alg = typeof decoded.header.alg === "string" ? decoded.header.alg : undefined;
|
|
||||||
if (!alg || !["RS256", "RS384", "RS512"].includes(alg)) {
|
|
||||||
return err({
|
|
||||||
code: "invalid_id_token",
|
|
||||||
message: `ID token verification failed: unsupported weak RSA fallback algorithm ${alg ?? "unknown"}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let jwk: JsonWebKey;
|
|
||||||
try {
|
|
||||||
jwk = await fetchSigningJwk(endpoints.jwksUri, decoded.header.kid);
|
|
||||||
} catch (cause) {
|
|
||||||
return err({
|
|
||||||
code: "invalid_id_token",
|
|
||||||
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = createPublicKey({ key: jwk, format: "jwk" });
|
|
||||||
const isValid = verify(
|
|
||||||
getNodeVerifyAlgorithm(alg),
|
|
||||||
Buffer.from(decoded.signingInput),
|
|
||||||
key,
|
|
||||||
decoded.signature,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return err({
|
return err({
|
||||||
code: "invalid_id_token",
|
code: "invalid_id_token",
|
||||||
message: "ID token signature verification failed",
|
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (cause) {
|
|
||||||
return err({
|
if (!hasWarnedWeakKeyMode) {
|
||||||
code: "invalid_id_token",
|
hasWarnedWeakKeyMode = true;
|
||||||
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const claimError = validateOidcClaims(decoded.payload, config.issuer, config.clientId, 60);
|
return err({
|
||||||
if (claimError) {
|
code: "invalid_id_token",
|
||||||
return err(claimError);
|
message: "ID token signature verification failed",
|
||||||
}
|
hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
|
||||||
|
});
|
||||||
if (decoded.payload.nonce !== expectedNonce) {
|
|
||||||
return err({
|
|
||||||
code: "nonce_mismatch",
|
|
||||||
message: `Nonce mismatch: expected ${expectedNonce}, got ${decoded.payload.nonce}`,
|
|
||||||
hint: "Please try signing in again. This can happen with stale browser sessions.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok(decoded.payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichWithUserInfo(
|
async function enrichWithUserInfo(
|
||||||
@@ -719,7 +741,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
function buildIdentity(claims: OidcClaims): OidcIdentity {
|
function buildIdentity(claims: OidcClaims): OidcIdentity {
|
||||||
const subject = resolveSubject(claims);
|
const subject = resolveSubject(claims);
|
||||||
if (!subject) {
|
if (!subject) {
|
||||||
throw new Error("OIDC subject was resolved before identity construction");
|
throw new Error("OIDC subject was not resolved before identity construction");
|
||||||
}
|
}
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
@@ -759,11 +781,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
|
|
||||||
function reload(newConfig: OidcConfig): void {
|
function reload(newConfig: OidcConfig): void {
|
||||||
config = Object.freeze({ ...newConfig });
|
config = Object.freeze({ ...newConfig });
|
||||||
|
maybeWarnWeakRsaMode(config);
|
||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSubjectClaimOrder(): string[] {
|
function getSubjectClaimOrder(): string[] {
|
||||||
return ["sub", ...(config.subjectClaims ?? []).filter((claim) => claim !== "sub")];
|
return [
|
||||||
|
"sub",
|
||||||
|
...normalizeSubjectClaims(config.subjectClaims).filter((claim) => claim !== "sub"),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSubject(claims: OidcClaims): string | undefined {
|
function resolveSubject(claims: OidcClaims): string | undefined {
|
||||||
@@ -778,6 +804,71 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { status, discover, startFlow, handleCallback, invalidate, reload };
|
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 {
|
function readClaimAsString(claims: Record<string, unknown>, claimName: string): string | undefined {
|
||||||
@@ -798,19 +889,7 @@ function computeS256Challenge(verifier: string): string {
|
|||||||
return createHash("sha256").update(verifier).digest("base64url");
|
return createHash("sha256").update(verifier).digest("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWeakRsaJoseError(cause: unknown): cause is Error {
|
function decodeJwtParts(token: string): DecodedJwtParts {
|
||||||
return (
|
|
||||||
cause instanceof Error &&
|
|
||||||
cause.message.includes("requires key modulusLength to be 2048 bits or larger")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeJwtParts(token: string): {
|
|
||||||
header: Record<string, unknown>;
|
|
||||||
payload: OidcClaims;
|
|
||||||
signature: Buffer;
|
|
||||||
signingInput: string;
|
|
||||||
} {
|
|
||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3) {
|
||||||
throw new Error("JWT must have exactly 3 parts");
|
throw new Error("JWT must have exactly 3 parts");
|
||||||
@@ -834,39 +913,22 @@ function decodeJwtParts(token: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSigningJwk(
|
function selectCandidateSigningKeys(
|
||||||
jwksUri: string,
|
keys: Array<JsonWebKey & { kid?: string }>,
|
||||||
expectedKid: unknown,
|
expectedKid: unknown,
|
||||||
): Promise<JsonWebKey & { kid?: string }> {
|
): Array<JsonWebKey & { kid?: string }> {
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
const kid = typeof expectedKid === "string" ? expectedKid : undefined;
|
const kid = typeof expectedKid === "string" ? expectedKid : undefined;
|
||||||
const jwk =
|
const rsaKeys = keys.filter((key) => key.kty === "RSA");
|
||||||
(kid ? keys.find((key) => key.kid === kid) : undefined) ??
|
if (kid) {
|
||||||
(keys.length === 1 ? keys[0] : undefined);
|
const matchingKey = rsaKeys.find((key) => key.kid === kid);
|
||||||
|
if (matchingKey) {
|
||||||
|
return [matchingKey];
|
||||||
|
}
|
||||||
|
|
||||||
if (!jwk) {
|
return [];
|
||||||
throw new Error(`No matching JWK found for kid ${kid ?? "(missing)"}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jwk.kty !== "RSA") {
|
return rsaKeys;
|
||||||
throw new Error(`Expected RSA signing key but received ${jwk.kty ?? "unknown"}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwk;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" {
|
function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" {
|
||||||
@@ -882,6 +944,47 @@ function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function validateOidcClaims(
|
||||||
payload: OidcClaims,
|
payload: OidcClaims,
|
||||||
expectedIssuer: string,
|
expectedIssuer: string,
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ integration:
|
|||||||
# - "open_id"
|
# - "open_id"
|
||||||
# - "email"
|
# - "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
|
# Extra query parameters can be passed to the authorization endpoint
|
||||||
# by setting them here. This is useful for providers that require any kind
|
# by setting them here. This is useful for providers that require any kind
|
||||||
# of custom hinting.
|
# of custom hinting.
|
||||||
|
|||||||
+25
-5
@@ -71,6 +71,7 @@ oidc:
|
|||||||
# userinfo_endpoint: ""
|
# userinfo_endpoint: ""
|
||||||
# scope: "openid email profile"
|
# scope: "openid email profile"
|
||||||
# subject_claims: ["open_id", "email"]
|
# subject_claims: ["open_id", "email"]
|
||||||
|
# allow_weak_rsa_keys: false
|
||||||
# extra_params:
|
# extra_params:
|
||||||
# foo: "bar"
|
# foo: "bar"
|
||||||
```
|
```
|
||||||
@@ -95,6 +96,24 @@ oidc:
|
|||||||
This keeps identity matching stable by preferring `open_id` and only falling
|
This keeps identity matching stable by preferring `open_id` and only falling
|
||||||
back to `email` if needed.
|
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
|
### PKCE
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
@@ -124,8 +143,9 @@ Headplane uses a two-step matching strategy:
|
|||||||
|
|
||||||
1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
|
1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
|
||||||
each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane
|
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
|
extracts the last path segment and compares it to the resolved OIDC subject.
|
||||||
OIDC token. If they match, the user is linked.
|
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
|
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
|
back to comparing the user's email address from the OIDC `userinfo` endpoint
|
||||||
@@ -234,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
|
- **Invalid API Key**: The `headscale.api_key` may have expired. Generate
|
||||||
a new one with `headscale apikeys create --expiration 999d`.
|
a new one with `headscale apikeys create --expiration 999d`.
|
||||||
|
|
||||||
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the
|
- **Missing the `sub` claim**: If your IdP omits `sub`, configure
|
||||||
ID token. This is required by the OIDC spec but some providers need explicit
|
`oidc.subject_claims` with a stable fallback such as `open_id`. Only use
|
||||||
configuration.
|
`email` as a fallback when it is stable for your users.
|
||||||
|
|
||||||
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
|
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
|
||||||
matches `{server.base_url}/admin/oidc/callback` exactly.
|
matches `{server.base_url}/admin/oidc/callback` exactly.
|
||||||
|
|||||||
@@ -130,6 +130,59 @@ describe("Configuration YAML file loading", () => {
|
|||||||
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]);
|
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 () => {
|
test("partial oidc config with enabled field can be parsed", async () => {
|
||||||
const filePath = "/config/oidc-partial.yaml";
|
const filePath = "/config/oidc-partial.yaml";
|
||||||
writeYaml(filePath, {
|
writeYaml(filePath, {
|
||||||
|
|||||||
@@ -42,8 +42,12 @@ function encodeBase64Url(value: string | Buffer) {
|
|||||||
return Buffer.from(value).toString("base64url");
|
return Buffer.from(value).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
function signWeakRs256IdToken(claims: Record<string, unknown>, nonce?: string) {
|
function signWeakRs256IdToken(
|
||||||
const header = { alg: "RS256", kid: "test-key", typ: "JWT" };
|
claims: Record<string, unknown>,
|
||||||
|
nonce?: string,
|
||||||
|
options?: { kid?: string },
|
||||||
|
) {
|
||||||
|
const header = { alg: "RS256", kid: options?.kid ?? "test-key", typ: "JWT" };
|
||||||
const payload = {
|
const payload = {
|
||||||
nonce,
|
nonce,
|
||||||
...claims,
|
...claims,
|
||||||
@@ -544,7 +548,7 @@ describe("handleCallback", () => {
|
|||||||
expect(result.error.code).toBe("missing_sub");
|
expect(result.error.code).toBe("missing_sub");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts RS256 id tokens signed with 1024-bit RSA keys", async () => {
|
test("rejects RS256 id tokens signed with 1024-bit RSA keys by default", async () => {
|
||||||
const originalPublicJwk = publicJwk;
|
const originalPublicJwk = publicJwk;
|
||||||
publicJwk = weakPublicJwk;
|
publicJwk = weakPublicJwk;
|
||||||
|
|
||||||
@@ -573,6 +577,47 @@ describe("handleCallback", () => {
|
|||||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||||
const result = await svc.handleCallback(params, flowState);
|
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);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return;
|
return;
|
||||||
@@ -584,6 +629,59 @@ describe("handleCallback", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("uses configured open_id fallback when sub claim is missing", async () => {
|
||||||
const svc = createOidcService(
|
const svc = createOidcService(
|
||||||
testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }),
|
testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }),
|
||||||
|
|||||||
Reference in New Issue
Block a user