455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
export interface OidcMetadata {
|
|
issuer: string;
|
|
authorization_endpoint: string;
|
|
token_endpoint: string;
|
|
userinfo_endpoint?: string;
|
|
jwks_uri: string;
|
|
scopes_supported?: string[];
|
|
}
|
|
|
|
function secureUrl(value: string, field: string): URL {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "https:" && url.hostname !== "localhost")
|
|
throw new Error(`WRN-IDENTITY-URL: ${field} must use HTTPS.`);
|
|
return url;
|
|
}
|
|
|
|
export async function discoverOidc(
|
|
issuer: string,
|
|
options: { fetch?: typeof fetch } = {},
|
|
): Promise<OidcMetadata> {
|
|
const expected = secureUrl(issuer, "issuer");
|
|
const endpoint = new URL(
|
|
".well-known/openid-configuration",
|
|
`${expected.href.replace(/\/$/, "")}/`,
|
|
);
|
|
const response = await (options.fetch ?? fetch)(endpoint, {
|
|
headers: { accept: "application/json" },
|
|
});
|
|
if (!response.ok) throw new Error(`WRN-OIDC-DISCOVERY: provider returned ${response.status}.`);
|
|
const metadata = (await response.json()) as OidcMetadata;
|
|
if (metadata.issuer.replace(/\/$/, "") !== expected.href.replace(/\/$/, ""))
|
|
throw new Error("WRN-OIDC-ISSUER: discovered issuer does not match configuration.");
|
|
for (const field of ["authorization_endpoint", "token_endpoint", "jwks_uri"] as const)
|
|
secureUrl(metadata[field], field);
|
|
return metadata;
|
|
}
|
|
|
|
export function oidcAuthorizationUrl(
|
|
metadata: OidcMetadata,
|
|
input: {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
state: string;
|
|
nonce: string;
|
|
codeChallenge: string;
|
|
scopes?: string[];
|
|
},
|
|
): string {
|
|
const url = secureUrl(metadata.authorization_endpoint, "authorization_endpoint");
|
|
const values = {
|
|
response_type: "code",
|
|
client_id: input.clientId,
|
|
redirect_uri: input.redirectUri,
|
|
scope: (input.scopes ?? ["openid", "profile", "email"]).join(" "),
|
|
state: input.state,
|
|
nonce: input.nonce,
|
|
code_challenge: input.codeChallenge,
|
|
code_challenge_method: "S256",
|
|
};
|
|
for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value);
|
|
return url.href;
|
|
}
|
|
|
|
export interface EnterpriseIdentity {
|
|
externalId: string;
|
|
username: string;
|
|
displayName?: string;
|
|
email?: string;
|
|
groups: string[];
|
|
active: boolean;
|
|
attributes?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface SamlAssertion {
|
|
id: string;
|
|
issuer: string;
|
|
audience: string;
|
|
recipient: string;
|
|
expiresAt: number;
|
|
identity: EnterpriseIdentity;
|
|
}
|
|
export interface SamlAdapter {
|
|
createLoginRequest(input: {
|
|
requestId: string;
|
|
callbackUrl: string;
|
|
relayState: string;
|
|
}): Promise<string> | string;
|
|
verifySignedResponse(response: string): Promise<SamlAssertion>;
|
|
}
|
|
export interface ReplayStore {
|
|
consume(id: string, expiresAt: number): Promise<boolean>;
|
|
}
|
|
export function memoryReplayStore(now: () => number = Date.now): ReplayStore {
|
|
const ids = new Map<string, number>();
|
|
return {
|
|
async consume(id, expiresAt) {
|
|
for (const [key, expiry] of ids) if (expiry <= now()) ids.delete(key);
|
|
if (ids.has(id)) return false;
|
|
ids.set(id, expiresAt);
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
export function createSamlFederation(options: {
|
|
adapter: SamlAdapter;
|
|
issuer: string;
|
|
audience: string;
|
|
recipient: string;
|
|
replayStore?: ReplayStore;
|
|
now?: () => number;
|
|
}) {
|
|
const replay = options.replayStore ?? memoryReplayStore(options.now);
|
|
const now = options.now ?? Date.now;
|
|
return {
|
|
login: options.adapter.createLoginRequest.bind(options.adapter),
|
|
async callback(encodedResponse: string): Promise<EnterpriseIdentity> {
|
|
const assertion = await options.adapter.verifySignedResponse(encodedResponse);
|
|
if (
|
|
assertion.issuer !== options.issuer ||
|
|
assertion.audience !== options.audience ||
|
|
assertion.recipient !== options.recipient
|
|
)
|
|
throw new Error("WRN-SAML-BOUNDARY: issuer, audience, or recipient mismatch.");
|
|
if (assertion.expiresAt <= now()) throw new Error("WRN-SAML-EXPIRED: assertion has expired.");
|
|
if (!(await replay.consume(assertion.id, assertion.expiresAt)))
|
|
throw new Error("WRN-SAML-REPLAY: assertion was already consumed.");
|
|
return assertion.identity;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface DirectoryAdapter {
|
|
kind: "ldap" | "active-directory";
|
|
search(input: {
|
|
baseDn: string;
|
|
filter: string;
|
|
attributes: string[];
|
|
signal?: AbortSignal;
|
|
}): Promise<EnterpriseIdentity[]>;
|
|
authenticate?(
|
|
username: string,
|
|
password: string,
|
|
signal?: AbortSignal,
|
|
): Promise<EnterpriseIdentity | null>;
|
|
}
|
|
export async function syncDirectory(
|
|
adapter: DirectoryAdapter,
|
|
options: {
|
|
baseDn: string;
|
|
filter?: string;
|
|
attributes?: string[];
|
|
signal?: AbortSignal;
|
|
upsert: (identity: EnterpriseIdentity) => void | Promise<void>;
|
|
disableMissing?: (externalIds: string[]) => void | Promise<void>;
|
|
},
|
|
) {
|
|
const identities = await adapter.search({
|
|
baseDn: options.baseDn,
|
|
filter: options.filter ?? "(objectClass=person)",
|
|
attributes: options.attributes ?? ["uid", "mail", "displayName", "memberOf"],
|
|
signal: options.signal,
|
|
});
|
|
for (const identity of identities) await options.upsert(identity);
|
|
await options.disableMissing?.(identities.map((identity) => identity.externalId));
|
|
return { provider: adapter.kind, synchronized: identities.length };
|
|
}
|
|
|
|
export interface ScimUser extends EnterpriseIdentity {
|
|
id: string;
|
|
/** RFC 7643 field accepted at the HTTP boundary. */
|
|
userName?: string;
|
|
schemas?: string[];
|
|
}
|
|
export interface ScimStore {
|
|
list(): Promise<ScimUser[]>;
|
|
get(id: string): Promise<ScimUser | null>;
|
|
create(user: Omit<ScimUser, "id">): Promise<ScimUser>;
|
|
update(id: string, user: Partial<ScimUser>): Promise<ScimUser | null>;
|
|
delete(id: string): Promise<boolean>;
|
|
}
|
|
export function memoryScimStore(): ScimStore {
|
|
const users = new Map<string, ScimUser>();
|
|
return {
|
|
async list() {
|
|
return [...users.values()];
|
|
},
|
|
async get(id) {
|
|
return users.get(id) ?? null;
|
|
},
|
|
async create(user) {
|
|
const value = { ...user, id: crypto.randomUUID() };
|
|
users.set(value.id, value);
|
|
return value;
|
|
},
|
|
async update(id, patch) {
|
|
const current = users.get(id);
|
|
if (!current) return null;
|
|
const value = { ...current, ...patch, id };
|
|
users.set(id, value);
|
|
return value;
|
|
},
|
|
async delete(id) {
|
|
return users.delete(id);
|
|
},
|
|
};
|
|
}
|
|
function constantTimeText(left: string, right: string): boolean {
|
|
const a = new TextEncoder().encode(left);
|
|
const b = new TextEncoder().encode(right);
|
|
let mismatch = a.length ^ b.length;
|
|
for (let index = 0; index < Math.max(a.length, b.length); index++)
|
|
mismatch |= (a[index] ?? 0) ^ (b[index] ?? 0);
|
|
return mismatch === 0;
|
|
}
|
|
const SCIM_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
export function createScimHandler(options: {
|
|
store: ScimStore;
|
|
bearerToken: string;
|
|
basePath?: string;
|
|
maxBodyBytes?: number;
|
|
}) {
|
|
if (options.bearerToken.length < 24)
|
|
throw new Error("WRN-SCIM-TOKEN: bearer token must contain at least 24 characters.");
|
|
const base = options.basePath ?? "/scim/v2";
|
|
return async (request: Request): Promise<Response> => {
|
|
if (
|
|
!constantTimeText(request.headers.get("authorization") ?? "", `Bearer ${options.bearerToken}`)
|
|
)
|
|
return Response.json({ detail: "Unauthorized" }, { status: 401 });
|
|
const url = new URL(request.url);
|
|
const match = new RegExp(
|
|
`^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/Users(?:/([^/]+))?$`,
|
|
).exec(url.pathname);
|
|
if (!match) return Response.json({ detail: "Not found" }, { status: 404 });
|
|
const id = match[1];
|
|
if (request.method === "GET" && !id) {
|
|
const values = await options.store.list();
|
|
return Response.json({
|
|
schemas: [SCIM_SCHEMA],
|
|
totalResults: values.length,
|
|
startIndex: 1,
|
|
itemsPerPage: values.length,
|
|
Resources: values,
|
|
});
|
|
}
|
|
if (request.method === "GET" && id) {
|
|
const value = await options.store.get(id);
|
|
return value ? Response.json(value) : Response.json({ detail: "Not found" }, { status: 404 });
|
|
}
|
|
if (["POST", "PUT", "PATCH"].includes(request.method)) {
|
|
const text = await request.text();
|
|
if (new TextEncoder().encode(text).byteLength > (options.maxBodyBytes ?? 64 * 1024))
|
|
return Response.json({ detail: "Too large" }, { status: 413 });
|
|
let body: ScimUser;
|
|
try {
|
|
body = JSON.parse(text) as ScimUser;
|
|
} catch {
|
|
return Response.json({ detail: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
if (!body || typeof body.userName !== "string")
|
|
return Response.json({ detail: "userName is required" }, { status: 400 });
|
|
const normalized = {
|
|
externalId: String(body.externalId ?? body.userName),
|
|
username: body.userName,
|
|
displayName: body.displayName,
|
|
email: body.email,
|
|
groups: Array.isArray(body.groups) ? body.groups : [],
|
|
active: body.active !== false,
|
|
attributes: body.attributes,
|
|
};
|
|
const value =
|
|
request.method === "POST"
|
|
? await options.store.create(normalized)
|
|
: id
|
|
? await options.store.update(id, normalized)
|
|
: null;
|
|
return value
|
|
? Response.json(value, { status: request.method === "POST" ? 201 : 200 })
|
|
: Response.json({ detail: "Not found" }, { status: 404 });
|
|
}
|
|
if (request.method === "DELETE" && id)
|
|
return new Response(null, { status: (await options.store.delete(id)) ? 204 : 404 });
|
|
return new Response("Method Not Allowed", { status: 405 });
|
|
};
|
|
}
|
|
|
|
export interface MachineCredential {
|
|
id: string;
|
|
ownerId: string;
|
|
kind: "api-key" | "service-account";
|
|
name: string;
|
|
scopes: string[];
|
|
secretHash: string;
|
|
createdAt: number;
|
|
expiresAt?: number;
|
|
revokedAt?: number;
|
|
}
|
|
const hex = (bytes: Uint8Array) =>
|
|
Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
async function hash(value: string): Promise<string> {
|
|
return hex(
|
|
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))),
|
|
);
|
|
}
|
|
export function createMachineIdentityManager(now: () => number = Date.now) {
|
|
const records = new Map<string, MachineCredential>();
|
|
return {
|
|
async issue(input: {
|
|
ownerId: string;
|
|
name: string;
|
|
scopes: string[];
|
|
kind?: MachineCredential["kind"];
|
|
expiresAt?: number;
|
|
}) {
|
|
const id = crypto.randomUUID();
|
|
const secret = `wrn_${id.replaceAll("-", "")}_${hex(crypto.getRandomValues(new Uint8Array(24)))}`;
|
|
const record: MachineCredential = {
|
|
id,
|
|
ownerId: input.ownerId,
|
|
kind: input.kind ?? "api-key",
|
|
name: input.name,
|
|
scopes: [...new Set(input.scopes)].sort(),
|
|
secretHash: await hash(secret),
|
|
createdAt: now(),
|
|
expiresAt: input.expiresAt,
|
|
};
|
|
records.set(id, record);
|
|
return { secret, credential: { ...record, secretHash: "[REDACTED]" } };
|
|
},
|
|
async authenticate(secret: string, requiredScope?: string) {
|
|
const digest = await hash(secret);
|
|
for (const record of records.values())
|
|
if (
|
|
constantTimeText(record.secretHash, digest) &&
|
|
!record.revokedAt &&
|
|
(!record.expiresAt || record.expiresAt > now()) &&
|
|
(!requiredScope || record.scopes.includes(requiredScope) || record.scopes.includes("*"))
|
|
)
|
|
return { ...record, secretHash: "[REDACTED]" };
|
|
return null;
|
|
},
|
|
revoke(id: string) {
|
|
const record = records.get(id);
|
|
if (!record) return false;
|
|
record.revokedAt = now();
|
|
return true;
|
|
},
|
|
list(ownerId: string) {
|
|
return [...records.values()]
|
|
.filter((record) => record.ownerId === ownerId)
|
|
.map((record) => ({ ...record, secretHash: "[REDACTED]" }));
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface GovernanceEvent {
|
|
id: string;
|
|
type: string;
|
|
subjectId: string;
|
|
actorId?: string;
|
|
createdAt: number;
|
|
data?: Record<string, unknown>;
|
|
}
|
|
export function createGovernance(
|
|
options: {
|
|
now?: () => number;
|
|
audit?: (event: GovernanceEvent) => void | Promise<void>;
|
|
exportSubject?: (subjectId: string) => unknown | Promise<unknown>;
|
|
deleteSubject?: (subjectId: string) => void | Promise<void>;
|
|
} = {},
|
|
) {
|
|
const now = options.now ?? Date.now;
|
|
const consents = new Map<
|
|
string,
|
|
Map<string, { granted: boolean; version: string; at: number }>
|
|
>();
|
|
const approvals = new Map<
|
|
string,
|
|
{
|
|
id: string;
|
|
subjectId: string;
|
|
action: "export" | "delete";
|
|
status: "pending" | "approved" | "rejected";
|
|
requestedAt: number;
|
|
decidedAt?: number;
|
|
decidedBy?: string;
|
|
}
|
|
>();
|
|
const emit = async (
|
|
type: string,
|
|
subjectId: string,
|
|
actorId?: string,
|
|
data?: Record<string, unknown>,
|
|
) =>
|
|
options.audit?.({ id: crypto.randomUUID(), type, subjectId, actorId, createdAt: now(), data });
|
|
return {
|
|
async consent(subjectId: string, purpose: string, granted: boolean, version: string) {
|
|
const values = consents.get(subjectId) ?? new Map();
|
|
const value = { granted, version, at: now() };
|
|
values.set(purpose, value);
|
|
consents.set(subjectId, values);
|
|
await emit("consent.changed", subjectId, subjectId, { purpose, granted, version });
|
|
return value;
|
|
},
|
|
consents(subjectId: string) {
|
|
return Object.fromEntries(consents.get(subjectId) ?? []);
|
|
},
|
|
async request(subjectId: string, action: "export" | "delete") {
|
|
const value = {
|
|
id: crypto.randomUUID(),
|
|
subjectId,
|
|
action,
|
|
status: "pending" as const,
|
|
requestedAt: now(),
|
|
};
|
|
approvals.set(value.id, value);
|
|
await emit(`privacy.${action}.requested`, subjectId);
|
|
return value;
|
|
},
|
|
async decide(id: string, actorId: string, approved: boolean) {
|
|
const request = approvals.get(id);
|
|
if (!request || request.status !== "pending")
|
|
throw new Error("WRN-GOVERNANCE-APPROVAL: request is missing or already decided.");
|
|
const decision = {
|
|
...request,
|
|
status: approved ? ("approved" as const) : ("rejected" as const),
|
|
decidedAt: now(),
|
|
decidedBy: actorId,
|
|
};
|
|
approvals.set(id, decision);
|
|
let result: unknown;
|
|
if (approved && request.action === "export")
|
|
result = await options.exportSubject?.(request.subjectId);
|
|
if (approved && request.action === "delete") await options.deleteSubject?.(request.subjectId);
|
|
await emit(`privacy.${request.action}.${decision.status}`, request.subjectId, actorId);
|
|
return { decision, result };
|
|
},
|
|
async enforceRetention(
|
|
records: Array<{ subjectId: string; createdAt: number }>,
|
|
maxAgeMs: number,
|
|
remove: (record: { subjectId: string; createdAt: number }) => void | Promise<void>,
|
|
) {
|
|
if (maxAgeMs < 0) throw new RangeError("retention duration must not be negative");
|
|
const expired = records.filter((record) => record.createdAt + maxAgeMs <= now());
|
|
for (const record of expired) {
|
|
await remove(record);
|
|
await emit("retention.deleted", record.subjectId, undefined, {
|
|
createdAt: record.createdAt,
|
|
});
|
|
}
|
|
return expired.length;
|
|
},
|
|
};
|
|
}
|