1
0
forked from baron/baron-sso

feat(headless-login): add jwks cache visibility and refresh flow

- replace inline headless jwks support with jwksUri-only validation
- add cached jwks refresh worker, manual refresh/revoke endpoints, and parsed key summaries
- expose allowed algorithms and key previews in DevFront with regression coverage
This commit is contained in:
Lectom C Han
2026-04-01 18:33:22 +09:00
parent f51cdba51a
commit 9facd24a00
20 changed files with 2393 additions and 499 deletions

View File

@@ -12,7 +12,6 @@ export type ClientSummary = {
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: string | Record<string, unknown>;
redirectUris: string[];
scopes: string[];
metadata?: Record<string, unknown>;
@@ -63,6 +62,27 @@ export type ClientDetailResponse = {
metadata?: Record<string, unknown>;
};
endpoints: ClientEndpoints;
headlessJwksCache?: {
clientId: string;
jwksUri: string;
cachedAt: string;
expiresAt: string;
lastCheckedAt?: string;
lastSuccessfulVerificationAt?: string;
lastRefreshStatus?: "success" | "failure" | "pending";
lastError?: string;
consecutiveFailures?: number;
cachedKids?: string[];
etag?: string;
lastModified?: string;
parsedKeys?: Array<{
kid?: string;
kty?: string;
use?: string;
alg?: string;
n?: string;
}>;
};
};
export type ClientUpsertRequest = {
@@ -76,7 +96,6 @@ export type ClientUpsertRequest = {
responseTypes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
@@ -182,6 +201,17 @@ export async function rotateClientSecret(clientId: string) {
return data;
}
export async function refreshHeadlessJwksCache(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/headless-jwks/refresh`,
);
return data;
}
export async function revokeHeadlessJwksCache(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}/headless-jwks/cache`);
}
export async function deleteClient(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}`);
}

View File

@@ -1,125 +0,0 @@
/**
* Key Utilities for converting various public key formats (PEM, OpenSSH) to JWKS.
*/
interface JWK {
kty: string;
n: string;
e: string;
kid?: string;
use?: string;
alg?: string;
}
/**
* Converts a Base64 string to a URL-safe Base64 string (RFC 7515).
*/
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
/**
* Extracts RSA Modulus (n) and Exponent (e) from a SubjectPublicKeyInfo (PEM).
* This is a simplified parser for common RSA keys.
*/
export function parsePemToJwk(pem: string): JWK | null {
try {
// Remove headers, footers and whitespace
pem
.replace(/-----BEGIN PUBLIC KEY-----/, "")
.replace(/-----END PUBLIC KEY-----/, "")
.replace(/\s/g, "");
// In a real browser environment without heavy libraries,
// we would need a full ASN.1 parser.
// For now, we recommend using JWKS or OpenSSH formats for reliability,
// or we can hint the user that complex PEMs might fail.
// However, we'll try to support a basic one.
return null; // Placeholder: PEM parsing is complex without libs.
} catch (e) {
console.error("Failed to parse PEM", e);
return null;
}
}
/**
* Parses an OpenSSH Public Key (ssh-rsa AAAA...) into a JWK.
*/
export function parseSshRsaToJwk(sshKey: string): JWK | null {
try {
const parts = sshKey.trim().split(" ");
if (parts.length < 2 || parts[0] !== "ssh-rsa") return null;
const keyData = atob(parts[1]);
let offset = 0;
const readBlob = () => {
const len =
(keyData.charCodeAt(offset) << 24) |
(keyData.charCodeAt(offset + 1) << 16) |
(keyData.charCodeAt(offset + 2) << 8) |
keyData.charCodeAt(offset + 3);
offset += 4;
const blob = keyData.slice(offset, offset + len);
offset += len;
return blob;
};
const type = readBlob(); // "ssh-rsa"
if (type !== "ssh-rsa") return null;
const eBlob = readBlob();
const nBlob = readBlob();
return {
kty: "RSA",
n: semanticsBase64Url(nBlob),
e: semanticsBase64Url(eBlob),
alg: "RS256",
use: "sig",
};
} catch (e) {
console.error("Failed to parse SSH key", e);
return null;
}
}
function semanticsBase64Url(blob: string): string {
// Ensure leading zero removal for BigInt representations if necessary
let start = 0;
while (start < blob.length && blob.charCodeAt(start) === 0) {
start++;
}
return toBase64Url(btoa(blob.slice(start)));
}
/**
* Tries to auto-detect and convert input to JWKS JSON string.
* Returns the original string if it's already JSON or conversion fails.
*/
export function tryConvertToJwks(input: string): string {
const trimmed = input.trim();
// 1. If it looks like JSON, return as is (validation happens in component)
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return trimmed;
}
// 2. Try SSH RSA
if (trimmed.startsWith("ssh-rsa")) {
const jwk = parseSshRsaToJwk(trimmed);
if (jwk) {
return JSON.stringify({ keys: [jwk] }, null, 2);
}
}
// 3. PEM (Simplified check)
if (trimmed.includes("BEGIN PUBLIC KEY")) {
// For PEM, we suggest the user uses JWKS or SSH-RSA for now
// as JS doesn't have a built-in ASN1 parser and we want to avoid heavy deps.
return trimmed;
}
return trimmed;
}