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

@@ -8,8 +8,7 @@ import {
} from "./helpers/devfront-fixtures";
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
const sshRsaPublicKey =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAABwECAwQFBgc= test@example";
const jwksUri = "https://rp.example.com/.well-known/jwks.json";
test.describe("DevFront clients lifecycle", () => {
test.beforeEach(async ({ page }) => {
@@ -123,7 +122,7 @@ test.describe("DevFront clients lifecycle", () => {
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
});
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
test("pkce headless login uses jwks uri only and shows cache actions", async ({
page,
}) => {
const state = {
@@ -131,10 +130,44 @@ test.describe("DevFront clients lifecycle", () => {
makeClient("client-headless-login", {
name: "Headless Login App",
type: "pkce",
headlessJwksCache: {
clientId: "client-headless-login",
jwksUri,
cachedAt: "2026-03-31T00:00:00.000Z",
expiresAt: "2026-04-01T00:00:00.000Z",
lastCheckedAt: "2026-03-31T12:00:00.000Z",
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
lastRefreshStatus: "success",
lastError: "",
consecutiveFailures: 0,
cachedKids: ["kid-1"],
etag: 'W/"cache-etag"',
lastModified: "Tue, 31 Mar 2026 00:00:00 GMT",
parsedKeys: [
{
kid: "kid-1",
kty: "RSA",
use: "sig",
alg: "RS256",
n: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
onRefreshHeadlessJwks(clientId: string) {
this.clients[0].headlessJwksCache = {
...this.clients[0].headlessJwksCache!,
lastRefreshStatus: "success",
lastCheckedAt: "2026-04-01T00:00:00.000Z",
};
expect(clientId).toBe("client-headless-login");
},
onRevokeHeadlessJwksCache(clientId: string) {
expect(clientId).toBe("client-headless-login");
},
};
await installDevApiMock(page, state);
@@ -147,16 +180,15 @@ test.describe("DevFront clients lifecycle", () => {
.click();
await expect(
page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i,
}),
page.getByRole("heading", { name: /공개키 등록|Public Key Registration/i }),
).toBeVisible();
await expect(
page.getByRole("radio", { name: /Inline Public Key|Inline/i }),
).toHaveCount(0);
await page
.getByPlaceholder(
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
)
.fill(sshRsaPublicKey);
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
.fill(jwksUri);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
@@ -171,25 +203,57 @@ test.describe("DevFront clients lifecycle", () => {
)
.toBe("private_key_jwt");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.headless_jwks as {
keys?: Array<{ kty?: string; alg?: string }>;
}
)?.keys?.[0]?.kty,
)
.toBe("RSA");
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
.toBe(jwksUri);
await expect(
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
).toBeVisible();
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
await expect(
page.getByText(/Allowed algorithms|허용 알고리즘/i),
).toBeVisible();
for (const algorithm of [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"EdDSA",
]) {
await expect(page.getByText(algorithm, { exact: true }).last()).toBeVisible();
}
await expect(
page.getByText("abcdefghij...0123456789", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /refresh|새로고침/i }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }),
).toBeVisible();
await page.getByRole("button", { name: /refresh|새로고침/i }).click();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.headless_jwks as {
keys?: Array<{ kty?: string; alg?: string }>;
}
)?.keys?.[0]?.alg,
)
.toBe("RS256");
.poll(() => state.clients[0]?.headlessJwksCache?.lastCheckedAt)
.toBe("2026-04-01T00:00:00.000Z");
page.removeAllListeners("dialog");
page.once("dialog", async (dialog) => {
expect(dialog.message()).toMatch(/revoke|삭제|cache/i);
await dialog.accept();
});
await page
.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i })
.click();
await expect
.poll(() => state.clients[0]?.headlessJwksCache)
.toBeUndefined();
await page.reload();
await expect(
@@ -197,10 +261,6 @@ test.describe("DevFront clients lifecycle", () => {
name: /공개키 등록|Public Key Registration/i,
}),
).toBeVisible();
await expect(
page.getByPlaceholder(
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
),
).toHaveValue(/"kty": "RSA"/);
await expect(page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i })).toHaveValue(jwksUri);
});
});

View File

@@ -15,6 +15,27 @@ export type Client = {
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: Record<string, unknown> | string;
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;
}>;
};
metadata?: Record<string, unknown>;
};
@@ -53,6 +74,8 @@ export type DevApiMockState = {
auditLogs?: AuditLog[];
onUpdateStatus?: (status: ClientStatus) => void;
onRotateSecret?: (newSecret: string) => void;
onRefreshHeadlessJwks?: (clientId: string) => void;
onRevokeHeadlessJwksCache?: (clientId: string) => void;
};
export function makeClient(
@@ -337,13 +360,6 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
}
if (pathname.startsWith("/api/v1/dev/clients/") && method === "DELETE") {
const clientId = parseClientId(pathname);
state.clients = state.clients.filter((client) => client.id !== clientId);
appendAuditLog("CLIENT_DELETE", "DELETE_CLIENT", clientId);
return route.fulfill({ status: 204 });
}
if (pathname.startsWith("/api/v1/dev/clients/") && method === "GET") {
const clientId = parseClientId(pathname);
const found = state.clients.find((client) => client.id === clientId);
@@ -357,9 +373,56 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
headlessJwksCache: found.headlessJwksCache,
});
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
pathname.endsWith("/headless-jwks/cache") &&
method === "DELETE"
) {
const clientId = pathname.split("/")[5] ?? "";
const found = state.clients.find((client) => client.id === clientId);
if (!found) return json(route, { error: "not found" }, 404);
found.headlessJwksCache = undefined;
state.onRevokeHeadlessJwksCache?.(clientId);
return route.fulfill({ status: 204 });
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
pathname.endsWith("/headless-jwks/refresh") &&
method === "POST"
) {
const clientId = pathname.split("/")[5] ?? "";
const found = state.clients.find((client) => client.id === clientId);
if (!found) return json(route, { error: "not found" }, 404);
state.onRefreshHeadlessJwks?.(clientId);
return json(route, {
client: found,
endpoints: {
discovery: "https://issuer/.well-known/openid-configuration",
issuer: "https://issuer",
authorization: "https://issuer/oauth2/auth",
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
headlessJwksCache: found.headlessJwksCache,
});
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
!pathname.endsWith("/headless-jwks/cache") &&
method === "DELETE"
) {
const clientId = parseClientId(pathname);
state.clients = state.clients.filter((client) => client.id !== clientId);
appendAuditLog("CLIENT_DELETE", "DELETE_CLIENT", clientId);
return route.fulfill({ status: 204 });
}
if (pathname === "/api/v1/dev/consents" && method === "GET") {
const subject = searchParams.get("subject") || "";
const clientId = searchParams.get("client_id") || "";