feat(auth): add generic OIDC login with mandatory groups
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
authorizationCodeGrant,
|
||||
buildAuthorizationUrl,
|
||||
calculatePKCECodeChallenge,
|
||||
customFetch,
|
||||
discovery,
|
||||
type Configuration,
|
||||
} from "openid-client";
|
||||
import { constants, createPublicKey, verify as verifySignature } from "node:crypto";
|
||||
|
||||
export interface OidcIdentity {
|
||||
issuer: string;
|
||||
subject: string;
|
||||
displayName?: string;
|
||||
groups: readonly string[];
|
||||
tokenExpiresAt: Date;
|
||||
}
|
||||
|
||||
export interface OidcProtocol {
|
||||
authorizationUrl(input: { state: string; nonce: string; codeVerifier: string }): Promise<URL>;
|
||||
callback(input: { currentUrl: URL; state: string; nonce: string; codeVerifier: string }): Promise<OidcIdentity>;
|
||||
diagnose(signal: AbortSignal): Promise<void>;
|
||||
verifyDeviceFlow?(signal: AbortSignal, present: (uri: string, code: string) => void): Promise<OidcIdentity>;
|
||||
}
|
||||
|
||||
export class OidcProtocolError extends Error {
|
||||
constructor() {
|
||||
super("oidc_protocol_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export interface OidcProtocolOptions {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
callbackUrl: string;
|
||||
scopes: readonly string[];
|
||||
groupsClaim: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const MAX_GROUPS = 128;
|
||||
const MAX_GROUP_LENGTH = 256;
|
||||
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
|
||||
const MAX_JWKS_BYTES = 1024 * 1024;
|
||||
const text = (value: unknown, maximum = 2048): value is string =>
|
||||
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
|
||||
|
||||
function configuredUrl(value: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new OidcProtocolError();
|
||||
return url;
|
||||
}
|
||||
|
||||
function httpsEndpoint(value: unknown): URL {
|
||||
if (!text(value, 2048)) throw new OidcProtocolError();
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new OidcProtocolError();
|
||||
return url;
|
||||
}
|
||||
|
||||
function groupsFromClaims(claims: Record<string, unknown>, name: string): string[] {
|
||||
const indirect = claims._claim_names;
|
||||
if ((indirect && typeof indirect === "object" && !Array.isArray(indirect)
|
||||
&& Object.prototype.hasOwnProperty.call(indirect, name))
|
||||
|| claims.hasgroups === true) throw new OidcProtocolError();
|
||||
const raw = claims[name];
|
||||
if (!Array.isArray(raw) || raw.length > MAX_GROUPS) throw new OidcProtocolError();
|
||||
const groups: string[] = [];
|
||||
const unique = new Set<string>();
|
||||
for (const group of raw) {
|
||||
if (!text(group, MAX_GROUP_LENGTH) || group.trim().length === 0 || unique.has(group)) throw new OidcProtocolError();
|
||||
unique.add(group);
|
||||
groups.push(group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function identityFromClaims(claims: Record<string, unknown>, options: OidcProtocolOptions): OidcIdentity {
|
||||
if (claims.iss !== options.issuer || !text(claims.sub, 512)) throw new OidcProtocolError();
|
||||
const audience = claims.aud;
|
||||
if (!(audience === options.clientId || (Array.isArray(audience) && audience.includes(options.clientId)))) {
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
if (typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || claims.exp * 1000 <= Date.now()) {
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
const tokenExpiresAt = new Date(claims.exp * 1000);
|
||||
if (Number.isNaN(tokenExpiresAt.getTime())) throw new OidcProtocolError();
|
||||
return {
|
||||
issuer: options.issuer,
|
||||
subject: claims.sub,
|
||||
...(text(claims.name, 256) ? { displayName: claims.name } : {}),
|
||||
groups: groupsFromClaims(claims, options.groupsClaim),
|
||||
tokenExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonPart(part: string): Record<string, unknown> {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(part) || part.length > MAX_ID_TOKEN_LENGTH) throw new OidcProtocolError();
|
||||
try {
|
||||
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(part, "base64url")));
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new OidcProtocolError();
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
}
|
||||
|
||||
function signatureAlgorithm(algorithm: string): {
|
||||
digest: string | null;
|
||||
pss?: boolean;
|
||||
saltLength?: number;
|
||||
ecdsaPartLength?: number;
|
||||
} {
|
||||
switch (algorithm) {
|
||||
case "RS256": return { digest: "RSA-SHA256" };
|
||||
case "RS384": return { digest: "RSA-SHA384" };
|
||||
case "RS512": return { digest: "RSA-SHA512" };
|
||||
case "PS256": return { digest: "sha256", pss: true, saltLength: 32 };
|
||||
case "PS384": return { digest: "sha384", pss: true, saltLength: 48 };
|
||||
case "PS512": return { digest: "sha512", pss: true, saltLength: 64 };
|
||||
case "ES256": return { digest: "sha256", ecdsaPartLength: 32 };
|
||||
case "ES384": return { digest: "sha384", ecdsaPartLength: 48 };
|
||||
case "ES512": return { digest: "sha512", ecdsaPartLength: 66 };
|
||||
case "EdDSA": return { digest: null };
|
||||
default: throw new OidcProtocolError();
|
||||
}
|
||||
}
|
||||
|
||||
function derLength(length: number): Buffer {
|
||||
if (length < 128) return Buffer.from([length]);
|
||||
if (length < 256) return Buffer.from([0x81, length]);
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
|
||||
function derInteger(raw: Buffer): Buffer {
|
||||
let start = 0;
|
||||
while (start < raw.length - 1 && raw[start] === 0) start += 1;
|
||||
let value = raw.subarray(start);
|
||||
if ((value[0] & 0x80) !== 0) value = Buffer.concat([Buffer.from([0]), value]);
|
||||
return Buffer.concat([Buffer.from([0x02]), derLength(value.length), value]);
|
||||
}
|
||||
|
||||
function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer {
|
||||
if (signature.length !== partLength * 2) throw new OidcProtocolError();
|
||||
const sequence = Buffer.concat([
|
||||
derInteger(signature.subarray(0, partLength)),
|
||||
derInteger(signature.subarray(partLength)),
|
||||
]);
|
||||
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
|
||||
}
|
||||
|
||||
async function verifyIdTokenSignature(
|
||||
idToken: unknown,
|
||||
config: Configuration,
|
||||
options: OidcProtocolOptions,
|
||||
): Promise<void> {
|
||||
if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError();
|
||||
const [protectedPart, payloadPart, signaturePart, extra] = idToken.split(".");
|
||||
if (!protectedPart || !payloadPart || !signaturePart || extra) throw new OidcProtocolError();
|
||||
const header = jsonPart(protectedPart);
|
||||
if (!text(header.alg, 16) || !text(header.kid, 256)) throw new OidcProtocolError();
|
||||
const metadata = config.serverMetadata();
|
||||
if (!Array.isArray(metadata.id_token_signing_alg_values_supported)
|
||||
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|
||||
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
|
||||
const jwksUrl = httpsEndpoint(metadata.jwks_uri);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (options.fetch ?? globalThis.fetch)(jwksUrl, {
|
||||
headers: { accept: "application/json" }, redirect: "error",
|
||||
});
|
||||
if (!response.ok) throw new OidcProtocolError();
|
||||
const body = await response.text();
|
||||
if (Buffer.byteLength(body, "utf8") > MAX_JWKS_BYTES) throw new OidcProtocolError();
|
||||
const parsed = JSON.parse(body) as { keys?: unknown };
|
||||
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError();
|
||||
const matching = parsed.keys.filter((key): key is Record<string, unknown> =>
|
||||
Boolean(key) && typeof key === "object" && !Array.isArray(key) && key.kid === header.kid);
|
||||
if (matching.length !== 1) throw new OidcProtocolError();
|
||||
const key = matching[0];
|
||||
if (key.use !== undefined && key.use !== "sig") throw new OidcProtocolError();
|
||||
if (key.alg !== undefined && key.alg !== header.alg) throw new OidcProtocolError();
|
||||
const algorithm = signatureAlgorithm(header.alg);
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(signaturePart)) throw new OidcProtocolError();
|
||||
const signature = Buffer.from(signaturePart, "base64url");
|
||||
if (signature.length === 0) throw new OidcProtocolError();
|
||||
const publicKey = createPublicKey({ key: key as never, format: "jwk" });
|
||||
const normalizedSignature = algorithm.ecdsaPartLength
|
||||
? joseEcdsaSignatureToDer(signature, algorithm.ecdsaPartLength)
|
||||
: signature;
|
||||
const verified = algorithm.pss
|
||||
? verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), {
|
||||
key: publicKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: algorithm.saltLength,
|
||||
}, normalizedSignature)
|
||||
: verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature);
|
||||
if (!verified) throw new OidcProtocolError();
|
||||
} catch (error) {
|
||||
if (error instanceof OidcProtocolError) throw error;
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
}
|
||||
|
||||
export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
||||
const issuerUrl = configuredUrl(options.issuer);
|
||||
const callbackUrl = configuredUrl(options.callbackUrl);
|
||||
if (!text(options.clientId, 512) || !text(options.clientSecret, 4096)
|
||||
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|
||||
|| options.scopes.some((scope) => !text(scope, 128))) throw new OidcProtocolError();
|
||||
|
||||
let discovered: Promise<Configuration> | undefined;
|
||||
const configuration = async (): Promise<Configuration> => {
|
||||
if (!discovered) {
|
||||
discovered = (async () => {
|
||||
try {
|
||||
const config = await discovery(
|
||||
issuerUrl,
|
||||
options.clientId,
|
||||
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
|
||||
undefined,
|
||||
options.fetch ? { [customFetch]: options.fetch as never } : undefined,
|
||||
);
|
||||
const metadata = config.serverMetadata();
|
||||
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
|
||||
httpsEndpoint(metadata.authorization_endpoint);
|
||||
httpsEndpoint(metadata.token_endpoint);
|
||||
httpsEndpoint(metadata.jwks_uri);
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error instanceof OidcProtocolError) throw error;
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
})();
|
||||
}
|
||||
return await discovered;
|
||||
};
|
||||
|
||||
return {
|
||||
async authorizationUrl(input) {
|
||||
try {
|
||||
const challenge = await calculatePKCECodeChallenge(input.codeVerifier);
|
||||
return buildAuthorizationUrl(await configuration(), {
|
||||
response_type: "code",
|
||||
redirect_uri: callbackUrl.href,
|
||||
scope: options.scopes.join(" "),
|
||||
state: input.state,
|
||||
nonce: input.nonce,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof OidcProtocolError) throw error;
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
},
|
||||
async callback(input) {
|
||||
if (input.currentUrl.origin !== callbackUrl.origin || input.currentUrl.pathname !== callbackUrl.pathname) {
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
try {
|
||||
const config = await configuration();
|
||||
const tokens = await authorizationCodeGrant(config, input.currentUrl, {
|
||||
expectedState: input.state,
|
||||
expectedNonce: input.nonce,
|
||||
pkceCodeVerifier: input.codeVerifier,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
await verifyIdTokenSignature(tokens.id_token, config, options);
|
||||
const claims = tokens.claims();
|
||||
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
|
||||
return identityFromClaims(claims as Record<string, unknown>, options);
|
||||
} catch (error) {
|
||||
if (error instanceof OidcProtocolError) throw error;
|
||||
throw new OidcProtocolError();
|
||||
}
|
||||
},
|
||||
async diagnose(signal) {
|
||||
signal.throwIfAborted();
|
||||
await configuration();
|
||||
signal.throwIfAborted();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user