release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+10
View File
@@ -0,0 +1,10 @@
# @wrnexus/identity
Enterprise identity and governance for WRNexusJS: OIDC discovery, signed SAML adapter flows,
LDAP/Active Directory synchronization adapters, SCIM provisioning, scoped API keys, service
accounts, approval workflows, consent history, retention, subject export/deletion and audit.
The package complements `@wrnexus/auth` (passkeys, MFA, devices, sessions, OAuth and audited
impersonation) and `@wrnexus/authz` (RBAC, ABAC and policy decisions). Protocol-specific SAML and
directory parsing is supplied through adapters so applications can select a maintained vendor SDK
without weakening framework validation, replay protection or governance auditing.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@wrnexus/identity",
"version": "0.8.0",
"type": "module",
"description": "Enterprise federation, provisioning, machine identity, and privacy governance for WRNexusJS.",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/auth": "workspace:*",
"@wrnexus/authz": "workspace:*",
"@wrnexus/oauth": "workspace:*"
}
}
+454
View File
@@ -0,0 +1,454 @@
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;
},
};
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import {
createGovernance,
createMachineIdentityManager,
createSamlFederation,
createScimHandler,
discoverOidc,
memoryScimStore,
oidcAuthorizationUrl,
syncDirectory,
} from "../src/index.ts";
describe("enterprise identity", () => {
test("discovers OIDC safely and creates PKCE authorization URLs", async () => {
const metadata = {
issuer: "https://id.test",
authorization_endpoint: "https://id.test/auth",
token_endpoint: "https://id.test/token",
jwks_uri: "https://id.test/jwks",
};
const discovered = await discoverOidc(metadata.issuer, {
fetch: (async () => Response.json(metadata)) as unknown as typeof fetch,
});
const url = new URL(
oidcAuthorizationUrl(discovered, {
clientId: "app",
redirectUri: "https://app.test/callback",
state: "state",
nonce: "nonce",
codeChallenge: "challenge",
}),
);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
test("validates signed SAML boundaries and prevents replay", async () => {
const assertion = {
id: "assertion-1",
issuer: "https://id.test",
audience: "app",
recipient: "https://app.test/saml",
expiresAt: 200,
identity: { externalId: "u1", username: "u1", groups: [], active: true },
};
const federation = createSamlFederation({
adapter: { createLoginRequest: () => "request", verifySignedResponse: async () => assertion },
issuer: assertion.issuer,
audience: assertion.audience,
recipient: assertion.recipient,
now: () => 100,
});
expect((await federation.callback("signed")).externalId).toBe("u1");
await expect(federation.callback("signed")).rejects.toThrow("WRN-SAML-REPLAY");
});
test("syncs LDAP/AD adapters and provisions SCIM users", async () => {
const values: string[] = [];
expect(
(
await syncDirectory(
{
kind: "active-directory",
search: async () => [
{ externalId: "u1", username: "user", groups: ["staff"], active: true },
],
},
{
baseDn: "dc=test",
upsert: (identity) => {
values.push(identity.externalId);
},
},
)
).synchronized,
).toBe(1);
const handler = createScimHandler({ store: memoryScimStore(), bearerToken: "a".repeat(32) });
const created = await handler(
new Request("https://app.test/scim/v2/Users", {
method: "POST",
headers: { authorization: `Bearer ${"a".repeat(32)}` },
body: JSON.stringify({ userName: "person@example.test", active: true }),
}),
);
expect(created.status).toBe(201);
expect(
(
await handler(
new Request("https://app.test/scim/v2/Users", {
headers: { authorization: `Bearer ${"a".repeat(32)}` },
}),
)
).status,
).toBe(200);
expect(values).toEqual(["u1"]);
});
test("issues scoped machine identities without exposing hashes", async () => {
const manager = createMachineIdentityManager(() => 100);
const issued = await manager.issue({
ownerId: "org",
name: "deploy",
kind: "service-account",
scopes: ["deploy:write"],
});
expect(issued.credential.secretHash).toBe("[REDACTED]");
expect((await manager.authenticate(issued.secret, "deploy:write"))?.kind).toBe(
"service-account",
);
expect(await manager.authenticate(issued.secret, "admin")).toBeNull();
});
test("tracks consent, approvals, export/deletion, retention and audit", async () => {
const audit: string[] = [];
const deleted: string[] = [];
const governance = createGovernance({
now: () => 100,
audit: (event) => {
audit.push(event.type);
},
exportSubject: (id) => ({ id }),
deleteSubject: (id) => {
deleted.push(id);
},
});
await governance.consent("u1", "analytics", true, "v2");
const exportRequest = await governance.request("u1", "export");
expect((await governance.decide(exportRequest.id, "admin", true)).result).toEqual({ id: "u1" });
const deleteRequest = await governance.request("u1", "delete");
await governance.decide(deleteRequest.id, "admin", true);
expect(
await governance.enforceRetention([{ subjectId: "u2", createdAt: 0 }], 50, (record) => {
deleted.push(record.subjectId);
}),
).toBe(1);
expect(deleted).toEqual(["u1", "u2"]);
expect(audit).toContain("consent.changed");
});
});