forked from baron/baron-sso
963 lines
30 KiB
TypeScript
963 lines
30 KiB
TypeScript
import type { Page, Route } from "@playwright/test";
|
|
|
|
export type ClientStatus = "active" | "inactive";
|
|
export type ClientType = "private" | "pkce";
|
|
|
|
export type Client = {
|
|
id: string;
|
|
name: string;
|
|
type: ClientType;
|
|
status: ClientStatus;
|
|
redirectUris: string[];
|
|
scopes: string[];
|
|
createdAt: string;
|
|
clientSecret?: string;
|
|
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>;
|
|
};
|
|
|
|
export type Consent = {
|
|
subject: string;
|
|
userName: string;
|
|
clientId: string;
|
|
clientName: string;
|
|
grantedScopes: string[];
|
|
authenticatedAt?: string;
|
|
createdAt: string;
|
|
deletedAt?: string;
|
|
status: "active" | "revoked";
|
|
tenantId: string;
|
|
tenantName: string;
|
|
rpMetadata?: Record<string, unknown>;
|
|
};
|
|
|
|
export type DeveloperRequestStatus = "pending" | "approved" | "rejected";
|
|
|
|
export type DeveloperRequest = {
|
|
id: string;
|
|
userId: string;
|
|
userName: string;
|
|
name?: string; // 추가
|
|
userEmail: string;
|
|
organization: string;
|
|
reason: string;
|
|
status: DeveloperRequestStatus;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
approvedAt?: string;
|
|
rejectedAt?: string;
|
|
comment?: string;
|
|
adminNotes?: string; // 추가
|
|
};
|
|
|
|
export type SeedAuthOptions = {
|
|
role?: string;
|
|
accessToken?: string;
|
|
idToken?: string;
|
|
refreshToken?: string;
|
|
sessionState?: string;
|
|
expiresInSeconds?: number;
|
|
state?: Record<string, unknown>;
|
|
profile?: Record<string, unknown>;
|
|
tenantId?: string;
|
|
companyCode?: string;
|
|
email?: string;
|
|
name?: string;
|
|
phone?: string;
|
|
};
|
|
|
|
export type ClientRelation = {
|
|
relation: string;
|
|
subject: string;
|
|
subjectType: string;
|
|
subjectId: string;
|
|
userName?: string;
|
|
userEmail?: string;
|
|
userLoginId?: string;
|
|
};
|
|
|
|
export type DevAssignableUser = {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
loginId?: string;
|
|
};
|
|
|
|
export type DevTenantSummary = {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
description?: string;
|
|
type?: string;
|
|
};
|
|
|
|
export type AuditLog = {
|
|
event_id: string;
|
|
timestamp: string;
|
|
user_id: string;
|
|
event_type: string;
|
|
status: "success" | "failure";
|
|
ip_address: string;
|
|
user_agent: string;
|
|
details: string;
|
|
};
|
|
|
|
export type DevApiMockState = {
|
|
clients: Client[];
|
|
consents: Consent[];
|
|
developerRequests?: DeveloperRequest[];
|
|
relations?: Record<string, ClientRelation[]>;
|
|
users?: DevAssignableUser[];
|
|
tenants?: DevTenantSummary[];
|
|
auditLogsByCursor?: Record<
|
|
string,
|
|
{ items: AuditLog[]; next_cursor?: string }
|
|
>;
|
|
auditLogs?: AuditLog[];
|
|
onUpdateStatus?: (status: ClientStatus) => void;
|
|
onRotateSecret?: (newSecret: string) => void;
|
|
onRefreshHeadlessJwks?: (clientId: string) => void;
|
|
onRevokeHeadlessJwksCache?: (clientId: string) => void;
|
|
mockRole?: string;
|
|
};
|
|
|
|
const seededRoles = new WeakMap<Page, string>();
|
|
|
|
export function makeClient(
|
|
id: string,
|
|
overrides: Partial<Client> = {},
|
|
): Client {
|
|
return {
|
|
id,
|
|
name: `${id} app`,
|
|
type: "private",
|
|
status: "active",
|
|
redirectUris: [`https://${id}.example.com/callback`],
|
|
scopes: ["openid", "profile", "email"],
|
|
createdAt: "2026-03-03T00:00:00.000Z",
|
|
clientSecret: `${id}-secret`,
|
|
metadata: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function resolveSeedAuthOptions(
|
|
roleOrOptions?: string | SeedAuthOptions,
|
|
): Required<Pick<SeedAuthOptions, "role">> & SeedAuthOptions {
|
|
if (typeof roleOrOptions === "string") {
|
|
return { role: roleOrOptions };
|
|
}
|
|
return { role: roleOrOptions?.role ?? "super_admin", ...roleOrOptions };
|
|
}
|
|
|
|
export async function getPersistedOidcUser(page: Page) {
|
|
return page.evaluate(() => {
|
|
const storage = window.localStorage;
|
|
for (let index = 0; index < storage.length; index += 1) {
|
|
const key = storage.key(index);
|
|
if (
|
|
key === null ||
|
|
!key.startsWith("oidc.user:") ||
|
|
!key.endsWith(":devfront")
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const rawValue = storage.getItem(key);
|
|
if (!rawValue) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(rawValue) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
});
|
|
}
|
|
|
|
export async function seedAuth(
|
|
page: Page,
|
|
roleOrOptions?: string | SeedAuthOptions,
|
|
) {
|
|
const options = resolveSeedAuthOptions(roleOrOptions);
|
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
|
const profile = {
|
|
sub: "playwright-user",
|
|
email: options.email ?? "playwright@example.com",
|
|
name: options.name ?? "Playwright User",
|
|
phone: options.phone ?? "",
|
|
role: options.profile?.role ?? options.role,
|
|
tenant_id: options.tenantId ?? "tenant-a",
|
|
companyCode: options.companyCode ?? "tenant-a",
|
|
...options.profile,
|
|
};
|
|
seededRoles.set(
|
|
page,
|
|
typeof profile.role === "string" ? profile.role : options.role,
|
|
);
|
|
|
|
await page.addInitScript(
|
|
({
|
|
issuedAt,
|
|
injectedRole,
|
|
injectedProfile,
|
|
injectedState,
|
|
injectedIdToken,
|
|
injectedAccessToken,
|
|
injectedRefreshToken,
|
|
injectedSessionState,
|
|
injectedExpiresInSeconds,
|
|
}) => {
|
|
(
|
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
|
)._IS_TEST_MODE = true;
|
|
|
|
const mockOidcUser = {
|
|
id_token: injectedIdToken,
|
|
session_state: injectedSessionState,
|
|
access_token: injectedAccessToken,
|
|
refresh_token: injectedRefreshToken,
|
|
token_type: "Bearer",
|
|
scope: "openid profile email",
|
|
profile: {
|
|
sub: "playwright-user",
|
|
email: "playwright@example.com",
|
|
name: "Playwright User",
|
|
phone: "",
|
|
role: injectedRole || "super_admin",
|
|
tenant_id: "tenant-a",
|
|
companyCode: "tenant-a",
|
|
...(injectedProfile || {}),
|
|
},
|
|
state: injectedState,
|
|
expires_at: issuedAt + injectedExpiresInSeconds,
|
|
};
|
|
|
|
const storageKeys = [
|
|
"user:http://localhost:5000/oidc:devfront",
|
|
"user:http://localhost:5000/oidc/:devfront",
|
|
"user:https://sso.example.test/oidc:devfront",
|
|
"user:https://sso.example.test/oidc/:devfront",
|
|
"oidc.user:http://localhost:5000/oidc:devfront",
|
|
"oidc.user:http://localhost:5000/oidc/:devfront",
|
|
"oidc.user:https://sso.example.test/oidc:devfront",
|
|
"oidc.user:https://sso.example.test/oidc/:devfront",
|
|
];
|
|
|
|
for (const key of storageKeys) {
|
|
window.localStorage.setItem(key, JSON.stringify(mockOidcUser));
|
|
window.sessionStorage.setItem(key, JSON.stringify(mockOidcUser));
|
|
}
|
|
|
|
window.localStorage.setItem("dev_role", injectedRole || "super_admin");
|
|
window.localStorage.setItem(
|
|
"dev_tenant_id",
|
|
typeof injectedProfile.tenant_id === "string"
|
|
? injectedProfile.tenant_id
|
|
: "tenant-a",
|
|
);
|
|
},
|
|
{
|
|
issuedAt: nowInSeconds,
|
|
injectedRole:
|
|
typeof profile.role === "string" ? profile.role : options.role,
|
|
injectedProfile: profile,
|
|
injectedState: options.state ?? { returnTo: "/clients" },
|
|
injectedIdToken: options.idToken ?? "playwright-id-token",
|
|
injectedAccessToken: options.accessToken ?? "playwright-access-token",
|
|
injectedRefreshToken: options.refreshToken ?? "playwright-refresh-token",
|
|
injectedSessionState: options.sessionState ?? "playwright-session",
|
|
injectedExpiresInSeconds: options.expiresInSeconds ?? 3600,
|
|
},
|
|
);
|
|
|
|
await page.route("**/oidc/**", async (route) => {
|
|
const url = route.request().url();
|
|
if (url.includes(".well-known/openid-configuration")) {
|
|
await route.fulfill({
|
|
json: {
|
|
issuer: "http://localhost:5000/oidc",
|
|
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
|
token_endpoint: "http://localhost:5000/oidc/token",
|
|
jwks_uri: "http://localhost:5000/oidc/jwks",
|
|
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
|
|
end_session_endpoint: "http://localhost:5000/oidc/session/end",
|
|
},
|
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
});
|
|
} else if (url.includes("/jwks")) {
|
|
await route.fulfill({
|
|
json: { keys: [] },
|
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
});
|
|
} else {
|
|
await route.fulfill({
|
|
status: 200,
|
|
body: "ok",
|
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function json(route: Route, payload: unknown, status = 200) {
|
|
return route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
function parseClientId(pathname: string): string {
|
|
const parts = pathname.split("/").filter(Boolean);
|
|
return parts[parts.length - 1] ?? "";
|
|
}
|
|
|
|
export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
|
const readMockRole = () =>
|
|
(state.mockRole ?? seededRoles.get(page) ?? "super_admin").trim();
|
|
|
|
const buildDeveloperAccessStatus = () => {
|
|
const requests = state.developerRequests ?? [];
|
|
const myRequests = requests.filter(
|
|
(request) => request.userId === "playwright-user",
|
|
);
|
|
const approvedPages = myRequests
|
|
.filter((request) => request.status === "approved")
|
|
.flatMap((request) => request.accessPages ?? ["all"]);
|
|
const pendingPages = myRequests
|
|
.filter((request) => request.status === "pending")
|
|
.flatMap((request) => request.accessPages ?? ["all"]);
|
|
|
|
const latestRequest = myRequests[myRequests.length - 1];
|
|
if (!latestRequest) {
|
|
return {
|
|
status: "none" as const,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: latestRequest.status,
|
|
approvedPages,
|
|
pendingPages,
|
|
};
|
|
};
|
|
|
|
const buildSelfConfigEditorRelation = (): ClientRelation => ({
|
|
relation: "config_editor",
|
|
subject: "User:playwright-user",
|
|
subjectType: "User",
|
|
subjectId: "playwright-user",
|
|
userName: "Playwright User",
|
|
userEmail: "playwright@example.com",
|
|
userLoginId: "playwright@example.com",
|
|
});
|
|
|
|
const shouldGrantDefaultEditRelation = (role: string) =>
|
|
role === "super_admin";
|
|
|
|
const resolveClientRelations = async (clientId: string) => {
|
|
const explicitRelations = state.relations?.[clientId];
|
|
if (explicitRelations) {
|
|
return explicitRelations;
|
|
}
|
|
|
|
const role = readMockRole();
|
|
if (!shouldGrantDefaultEditRelation(role)) {
|
|
return [];
|
|
}
|
|
|
|
return [buildSelfConfigEditorRelation()];
|
|
};
|
|
|
|
const appendAuditLog = (
|
|
eventType: string,
|
|
action: string,
|
|
targetId: string,
|
|
status: "success" | "failure" = "success",
|
|
) => {
|
|
if (!state.auditLogs) return;
|
|
state.auditLogs.unshift({
|
|
event_id: `evt-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
timestamp: new Date().toISOString(),
|
|
user_id: "playwright-user",
|
|
event_type: eventType,
|
|
status,
|
|
ip_address: "127.0.0.1",
|
|
user_agent: "playwright",
|
|
details: JSON.stringify({
|
|
action,
|
|
target_id: targetId,
|
|
tenant_id: "tenant-a",
|
|
}),
|
|
});
|
|
};
|
|
|
|
await page.route("**/api/v1/user/me", async (route) => {
|
|
const storedRole = readMockRole();
|
|
return json(route, {
|
|
id: "playwright-user",
|
|
loginId: "playwright@example.com",
|
|
email: "playwright@example.com",
|
|
name: "Playwright User",
|
|
phoneNumber: "",
|
|
department: "QA",
|
|
tenantId: "tenant-a",
|
|
tenantName: "Tenant A",
|
|
role: storedRole,
|
|
createdAt: "2026-03-03T00:00:00.000Z",
|
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/v1/dev/**", async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
const { pathname, searchParams } = url;
|
|
const method = request.method();
|
|
|
|
if (
|
|
(pathname === "/api/v1/dev/requests" ||
|
|
pathname === "/api/v1/dev/developer-request/list") &&
|
|
method === "GET"
|
|
) {
|
|
return json(route, state.developerRequests ?? []);
|
|
}
|
|
|
|
if (
|
|
(pathname === "/api/v1/dev/requests" ||
|
|
pathname === "/api/v1/dev/developer-request") &&
|
|
method === "POST"
|
|
) {
|
|
const payload =
|
|
(request.postDataJSON() as {
|
|
name?: string;
|
|
organization?: string;
|
|
reason?: string;
|
|
}) || {};
|
|
const created: DeveloperRequest = {
|
|
id: `req-${Date.now()}`,
|
|
userId: "playwright-user",
|
|
userName: payload.name ?? "Playwright User",
|
|
name: payload.name ?? "Playwright User",
|
|
userEmail: "playwright@example.com",
|
|
organization: payload.organization ?? "Unknown",
|
|
reason: payload.reason ?? "No reason",
|
|
status: "pending",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
if (!state.developerRequests) {
|
|
state.developerRequests = [];
|
|
}
|
|
state.developerRequests.push(created);
|
|
return json(route, created, 201);
|
|
}
|
|
|
|
if (
|
|
(pathname === "/api/v1/dev/requests/status" ||
|
|
pathname === "/api/v1/dev/developer-request/status") &&
|
|
method === "GET"
|
|
) {
|
|
return json(route, buildDeveloperAccessStatus());
|
|
}
|
|
|
|
if (
|
|
(pathname.startsWith("/api/v1/dev/requests/") ||
|
|
pathname.startsWith("/api/v1/dev/developer-request/")) &&
|
|
pathname.endsWith("/approve") &&
|
|
method === "POST"
|
|
) {
|
|
const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? "";
|
|
const found = state.developerRequests?.find((r) => r.id === reqId);
|
|
if (!found) return json(route, { error: "not found" }, 404);
|
|
found.status = "approved";
|
|
found.approvedAt = new Date().toISOString();
|
|
return json(route, found);
|
|
}
|
|
|
|
if (
|
|
(pathname.startsWith("/api/v1/dev/requests/") ||
|
|
pathname.startsWith("/api/v1/dev/developer-request/")) &&
|
|
pathname.endsWith("/reject") &&
|
|
method === "POST"
|
|
) {
|
|
const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? "";
|
|
const found = state.developerRequests?.find((r) => r.id === reqId);
|
|
if (!found) return json(route, { error: "not found" }, 404);
|
|
found.status = "rejected";
|
|
found.rejectedAt = new Date().toISOString();
|
|
return json(route, found);
|
|
}
|
|
|
|
if (
|
|
(pathname.startsWith("/api/v1/dev/requests/") ||
|
|
pathname.startsWith("/api/v1/dev/developer-request/")) &&
|
|
(pathname.endsWith("/cancel") || pathname.endsWith("/cancel-approval")) &&
|
|
method === "POST"
|
|
) {
|
|
const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? "";
|
|
const found = state.developerRequests?.find((r) => r.id === reqId);
|
|
if (!found) return json(route, { error: "not found" }, 404);
|
|
found.status = "pending";
|
|
found.approvedAt = undefined;
|
|
return json(route, found);
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
|
|
return json(
|
|
route,
|
|
state.tenants ?? [
|
|
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
|
],
|
|
);
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/stats" && method === "GET") {
|
|
const total = state.clients.length;
|
|
return json(route, {
|
|
total_clients: total,
|
|
active_sessions: Math.max(1, total),
|
|
auth_failures_24h: 0,
|
|
});
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/rp-usage/daily" && method === "GET") {
|
|
return json(route, {
|
|
items: [],
|
|
days: Number.parseInt(searchParams.get("days") || "14", 10),
|
|
period:
|
|
(searchParams.get("period") as "day" | "week" | "month") || "day",
|
|
});
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/clients" && method === "GET") {
|
|
return json(route, {
|
|
items: state.clients.map((client) => ({
|
|
id: client.id,
|
|
name: client.name,
|
|
type: client.type,
|
|
status: client.status,
|
|
createdAt: client.createdAt,
|
|
redirectUris: client.redirectUris,
|
|
scopes: client.scopes,
|
|
metadata: client.metadata ?? {},
|
|
})),
|
|
limit: 50,
|
|
offset: 0,
|
|
});
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/users" && method === "GET") {
|
|
const search = (searchParams.get("search") || "").toLowerCase();
|
|
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
|
|
const items = (state.users ?? [])
|
|
.filter((user) => {
|
|
if (!search) return true;
|
|
return [user.name, user.email, user.loginId ?? ""].some((value) =>
|
|
value.toLowerCase().includes(search),
|
|
);
|
|
})
|
|
.slice(0, Number.isFinite(limit) ? limit : 10);
|
|
return json(route, { items });
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/clients" && method === "POST") {
|
|
const payload = (request.postDataJSON() as {
|
|
name?: string;
|
|
type?: ClientType;
|
|
status?: ClientStatus;
|
|
redirectUris?: string[];
|
|
scopes?: string[];
|
|
tokenEndpointAuthMethod?: string;
|
|
jwksUri?: string;
|
|
jwks?: Record<string, unknown> | string;
|
|
metadata?: Record<string, unknown>;
|
|
}) || { name: "created app" };
|
|
|
|
const created = makeClient(`client-${state.clients.length + 1}`, {
|
|
name: payload.name ?? "created app",
|
|
type: payload.type ?? "private",
|
|
status: payload.status ?? "active",
|
|
redirectUris: payload.redirectUris ?? [],
|
|
scopes: payload.scopes ?? ["openid"],
|
|
tokenEndpointAuthMethod: payload.tokenEndpointAuthMethod,
|
|
jwksUri: payload.jwksUri,
|
|
jwks: payload.jwks,
|
|
metadata: payload.metadata ?? {},
|
|
});
|
|
|
|
state.clients.push(created);
|
|
if (!state.relations) {
|
|
state.relations = {};
|
|
}
|
|
state.relations[created.id] = [buildSelfConfigEditorRelation()];
|
|
appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id);
|
|
return json(route, {
|
|
client: created,
|
|
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",
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
pathname.startsWith("/api/v1/dev/clients/") &&
|
|
pathname.endsWith("/relations") &&
|
|
method === "GET"
|
|
) {
|
|
const clientId = pathname.split("/")[5] ?? "";
|
|
return json(route, {
|
|
items: await resolveClientRelations(clientId),
|
|
});
|
|
}
|
|
|
|
if (
|
|
pathname.startsWith("/api/v1/dev/clients/") &&
|
|
pathname.endsWith("/relations") &&
|
|
method === "POST"
|
|
) {
|
|
const clientId = pathname.split("/")[5] ?? "";
|
|
const payload = (request.postDataJSON() as {
|
|
relation?: string;
|
|
subject?: string;
|
|
userId?: string;
|
|
}) || { relation: "config_editor" };
|
|
const subject =
|
|
payload.subject ||
|
|
(payload.userId ? `User:${payload.userId}` : "User:playwright-user");
|
|
const subjectId = subject.startsWith("User:")
|
|
? subject.slice("User:".length)
|
|
: subject;
|
|
const created: ClientRelation = {
|
|
relation: payload.relation ?? "config_editor",
|
|
subject,
|
|
subjectType: "User",
|
|
subjectId,
|
|
};
|
|
if (!state.relations) {
|
|
state.relations = {};
|
|
}
|
|
if (!state.relations[clientId]) {
|
|
state.relations[clientId] = [];
|
|
}
|
|
state.relations[clientId].push(created);
|
|
appendAuditLog("CLIENT_RELATION_CREATE", "ADD_RELATION", clientId);
|
|
return json(route, created, 201);
|
|
}
|
|
|
|
if (
|
|
pathname.startsWith("/api/v1/dev/clients/") &&
|
|
pathname.endsWith("/relations") &&
|
|
method === "DELETE"
|
|
) {
|
|
const clientId = pathname.split("/")[5] ?? "";
|
|
const relation = searchParams.get("relation") || "";
|
|
const subject = searchParams.get("subject") || "";
|
|
if (state.relations?.[clientId]) {
|
|
state.relations[clientId] = state.relations[clientId].filter(
|
|
(item) => !(item.relation === relation && item.subject === subject),
|
|
);
|
|
}
|
|
appendAuditLog("CLIENT_RELATION_DELETE", "REMOVE_RELATION", clientId);
|
|
return route.fulfill({ status: 204 });
|
|
}
|
|
|
|
if (
|
|
pathname.startsWith("/api/v1/dev/clients/") &&
|
|
pathname.endsWith("/status") &&
|
|
method === "PATCH"
|
|
) {
|
|
const clientId = pathname.split("/")[5] ?? "";
|
|
const payload = request.postDataJSON() as { status: ClientStatus };
|
|
const found = state.clients.find((client) => client.id === clientId);
|
|
if (!found) return json(route, { error: "not found" }, 404);
|
|
found.status = payload.status;
|
|
appendAuditLog("CLIENT_UPDATE_STATUS", "UPDATE_CLIENT_STATUS", clientId);
|
|
state.onUpdateStatus?.(payload.status);
|
|
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("/secret/rotate") &&
|
|
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);
|
|
found.clientSecret = `${clientId}-rotated-secret`;
|
|
appendAuditLog("CLIENT_ROTATE_SECRET", "ROTATE_SECRET", clientId);
|
|
state.onRotateSecret?.(found.clientSecret);
|
|
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.includes("/users/") &&
|
|
pathname.endsWith("/metadata") &&
|
|
method === "GET"
|
|
) {
|
|
const parts = pathname.split("/").filter(Boolean);
|
|
const clientId = parts[4] ?? "";
|
|
const userId = parts[6] ?? "";
|
|
const target = state.consents.find(
|
|
(row) => row.clientId === clientId && row.subject === userId,
|
|
);
|
|
return json(route, {
|
|
clientId,
|
|
userId,
|
|
metadata: target?.rpMetadata ?? {},
|
|
});
|
|
}
|
|
|
|
if (
|
|
pathname.startsWith("/api/v1/dev/clients/") &&
|
|
pathname.includes("/users/") &&
|
|
pathname.endsWith("/metadata") &&
|
|
method === "PUT"
|
|
) {
|
|
const parts = pathname.split("/").filter(Boolean);
|
|
const clientId = parts[4] ?? "";
|
|
const userId = parts[6] ?? "";
|
|
const payload = (request.postDataJSON() as {
|
|
metadata?: Record<string, unknown>;
|
|
}) || { metadata: {} };
|
|
const target = state.consents.find(
|
|
(row) => row.clientId === clientId && row.subject === userId,
|
|
);
|
|
if (target) {
|
|
target.rpMetadata = payload.metadata ?? {};
|
|
}
|
|
return json(route, {
|
|
clientId,
|
|
userId,
|
|
metadata: payload.metadata ?? {},
|
|
});
|
|
}
|
|
|
|
if (pathname.startsWith("/api/v1/dev/clients/") && method === "PUT") {
|
|
const clientId = parseClientId(pathname);
|
|
const payload = (request.postDataJSON() as {
|
|
name?: string;
|
|
type?: ClientType;
|
|
scopes?: string[];
|
|
redirectUris?: string[];
|
|
tokenEndpointAuthMethod?: string;
|
|
jwksUri?: string;
|
|
jwks?: Record<string, unknown> | string;
|
|
metadata?: Record<string, unknown>;
|
|
}) || { name: "updated app" };
|
|
const found = state.clients.find((client) => client.id === clientId);
|
|
if (!found) return json(route, { error: "not found" }, 404);
|
|
if (payload.name) found.name = payload.name;
|
|
if (payload.type) found.type = payload.type;
|
|
if (payload.scopes) found.scopes = payload.scopes;
|
|
if (payload.redirectUris) found.redirectUris = payload.redirectUris;
|
|
if (payload.tokenEndpointAuthMethod !== undefined) {
|
|
found.tokenEndpointAuthMethod = payload.tokenEndpointAuthMethod;
|
|
}
|
|
if (payload.jwksUri !== undefined) {
|
|
found.jwksUri = payload.jwksUri;
|
|
}
|
|
if (payload.jwks !== undefined) {
|
|
found.jwks = payload.jwks;
|
|
}
|
|
if (payload.metadata) found.metadata = payload.metadata;
|
|
appendAuditLog("CLIENT_UPDATE", "UPDATE_CLIENT", 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/") && method === "GET") {
|
|
const clientId = parseClientId(pathname);
|
|
const found = state.clients.find((client) => client.id === clientId);
|
|
if (!found) return json(route, { error: "forbidden" }, 403);
|
|
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 = 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") || "";
|
|
const status = searchParams.get("status") || "";
|
|
const items = state.consents.filter((row) => {
|
|
const matchesSubject =
|
|
!subject ||
|
|
row.subject.includes(subject) ||
|
|
row.userName.includes(subject);
|
|
const matchesClientId = !clientId || row.clientId === clientId;
|
|
const matchesStatus = !status || row.status === status;
|
|
return matchesSubject && matchesClientId && matchesStatus;
|
|
});
|
|
return json(route, { items });
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/consents" && method === "DELETE") {
|
|
const subject = searchParams.get("subject") || "";
|
|
const clientId = searchParams.get("client_id") || "";
|
|
const target = state.consents.find(
|
|
(row) => row.subject === subject && row.clientId === clientId,
|
|
);
|
|
if (target) {
|
|
target.status = "revoked";
|
|
target.deletedAt = "2026-03-03T10:00:00.000Z";
|
|
}
|
|
return route.fulfill({ status: 204 });
|
|
}
|
|
|
|
if (pathname === "/api/v1/dev/audit-logs" && method === "GET") {
|
|
if (state.auditLogsByCursor) {
|
|
const cursor = searchParams.get("cursor") || "";
|
|
const pageSet = state.auditLogsByCursor[cursor] ?? { items: [] };
|
|
return json(route, {
|
|
items: pageSet.items,
|
|
limit: 50,
|
|
cursor: cursor || undefined,
|
|
next_cursor: pageSet.next_cursor,
|
|
});
|
|
}
|
|
|
|
if (state.auditLogs) {
|
|
const action = searchParams.get("action") || "";
|
|
const clientId = searchParams.get("client_id") || "";
|
|
const status = searchParams.get("status") || "";
|
|
const filtered = state.auditLogs.filter((item) => {
|
|
let parsedDetails: { action?: string; target_id?: string } = {};
|
|
try {
|
|
parsedDetails = JSON.parse(item.details) as {
|
|
action?: string;
|
|
target_id?: string;
|
|
};
|
|
} catch {}
|
|
const matchesAction = !action || parsedDetails.action === action;
|
|
const matchesClient =
|
|
!clientId || parsedDetails.target_id === clientId;
|
|
const matchesStatus = !status || item.status === status;
|
|
return matchesAction && matchesClient && matchesStatus;
|
|
});
|
|
return json(route, { items: filtered, limit: 50 });
|
|
}
|
|
|
|
return json(route, { items: [], limit: 50 });
|
|
}
|
|
|
|
return json(route, { error: `Unhandled ${method} ${pathname}` }, 404);
|
|
});
|
|
}
|