Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions test/auth-error-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

import {
CAIL_AUTH_ERROR_CODES,
createCailAuthError,
isCailAuthLaunch,
parseCailAuthErrorEnvelope,
serializeCailAuthError,
} from "../src/index.js";

interface StringSchema {
minLength?: number;
pattern?: string;
enum?: string[];
oneOf?: Array<{ pattern: string }>;
}

interface ObjectSchema {
required: string[];
additionalProperties: boolean;
}

// SAFETY: this checked-in contract fixture is parsed only for the schema
// fields declared here; each test compares them against package behavior.
const contract = JSON.parse(
readFileSync(
new URL("../contract/auth-error-envelope-v1.json", import.meta.url),
"utf8",
),
) as ObjectSchema & {
properties: {
error: ObjectSchema & {
properties: {
code: StringSchema;
message: StringSchema;
launch: StringSchema;
};
};
};
examples: Array<{ error: { code: string; message: string; launch?: string } }>;
};

const errorSchema = contract.properties.error;
const { code, message, launch } = errorSchema.properties;

function contractMessage(value: string): boolean {
return (
value.length >= (message.minLength ?? 0) &&
new RegExp(message.pattern ?? "", "u").test(value)
);
}

function contractLaunch(value: string): boolean {
const matches = (launch.oneOf ?? []).filter((branch) =>
new RegExp(branch.pattern, "u").test(value),
);
return matches.length === 1;
}

describe("auth-error-envelope-v1 contract", () => {
it("defines exactly the code set the package accepts", () => {
expect(CAIL_AUTH_ERROR_CODES).toEqual(code.enum);
for (const value of code.enum ?? []) {
expect(
parseCailAuthErrorEnvelope({ error: { code: value, message: "x" } }),
value,
).not.toBeNull();
}
});

it("produces every contract code in the contract shape", () => {
const allowedError = Object.keys(errorSchema.properties);
for (const value of CAIL_AUTH_ERROR_CODES) {
const body = JSON.parse(
serializeCailAuthError(createCailAuthError(value, "Sign in.", "/launch")),
);
expect(Object.keys(body)).toEqual(contract.required);
for (const field of errorSchema.required) {
expect(body.error, `${value}.${field}`).toHaveProperty(field);
}
for (const field of Object.keys(body.error)) {
expect(allowedError, `${value}.${field}`).toContain(field);
}
expect(code.enum).toContain(body.error.code);
expect(contractMessage(body.error.message)).toBe(true);
expect(contractLaunch(body.error.launch)).toBe(true);
}
});

it("round-trips each contract example unchanged", () => {
for (const example of contract.examples) {
const parsed = parseCailAuthErrorEnvelope(example);
expect(parsed).toEqual(example);
expect(JSON.parse(serializeCailAuthError(parsed!))).toEqual(example);
}
});

it("rejects a missing required field or an undeclared field at each level", () => {
const [example] = contract.examples;
const base = { code: example!.error.code, message: example!.error.message };
for (const field of errorSchema.required) {
const error = Object.fromEntries(
Object.entries(base).filter(([name]) => name !== field),
);
expect(parseCailAuthErrorEnvelope({ error }), field).toBeNull();
}
expect(parseCailAuthErrorEnvelope({})).toBeNull();
expect(contract.additionalProperties).toBe(false);
expect(errorSchema.additionalProperties).toBe(false);
expect(parseCailAuthErrorEnvelope({ error: base, extra: "x" })).toBeNull();
expect(
parseCailAuthErrorEnvelope({ error: { ...base, extra: "x" } }),
).toBeNull();
});

it("accepts exactly the messages the contract pattern accepts", () => {
const candidates = [
"Sign in to continue.",
"",
" ",
"line\nbreak",
"tab\there",
"nul\u0000",
"unit\u001f",
"del\u007f",
"c1\u0085",
"Ünïcode ✓",
"separator\u2028",
];
for (const value of candidates) {
const accepted =
parseCailAuthErrorEnvelope({
error: { code: "authentication_required", message: value },
}) !== null;
expect(accepted, JSON.stringify(value)).toBe(contractMessage(value));
}
});

it("accepts exactly the launches the contract patterns accept", () => {
const origin = "https://tools.ailab.gc.cuny.edu";
const paths = [
"",
"/",
"/launch/agent-studio",
"/site-studio/",
"/a..b/c_d~e",
"/launch//agent-studio",
"/launch/./agent-studio",
"/launch/../agent-studio",
"/.hidden",
"/-dash",
"/launch/agent-studio?next=/",
"/launch/agent-studio?",
"/launch/agent-studio#fragment",
"/launch\\agent-studio",
"/%2e%2e/agent-studio",
"/launch/agent\nstudio",
"/launch ",
];
const candidates = [
...paths,
...paths.map((path) => `${origin}${path}`),
"launch/agent-studio",
"//evil.example/launch",
"https://evil.example/launch",
`${origin}.evil.example/launch`,
`${origin}:443/launch`,
"https://TOOLS.AILAB.GC.CUNY.EDU/launch",
"http://tools.ailab.gc.cuny.edu/launch",
"https://user@tools.ailab.gc.cuny.edu/launch",
];
for (const value of candidates) {
expect(isCailAuthLaunch(value), JSON.stringify(value)).toBe(
contractLaunch(value),
);
const accepted =
parseCailAuthErrorEnvelope({
error: {
code: "authentication_required",
message: "Sign in.",
launch: value,
},
}) !== null;
expect(accepted, JSON.stringify(value)).toBe(contractLaunch(value));
}
});
});
13 changes: 1 addition & 12 deletions test/auth-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";

import {
CAIL_AUTH_ERROR_CODES,
CAIL_CANONICAL_ORIGIN,
createCailAuthError,
isCailAuthLaunch,
Expand Down Expand Up @@ -32,17 +31,7 @@ describe("CAIL auth error envelope", () => {
expect(Object.isFrozen(envelope.error)).toBe(true);
});

it("accepts only the active finite auth code set", () => {
expect(CAIL_AUTH_ERROR_CODES).toEqual([
"authentication_required",
"authentication_failed",
"invalid_credential",
"session_invalid",
"admission_required",
"admission_unavailable",
"identity_unavailable",
"identity_verification_misconfigured",
]);
it("parses a code-only envelope without a launch", () => {
expect(
parseCailAuthErrorJson(
'{"error":{"code":"admission_required","message":"Request access."}}',
Expand Down
165 changes: 165 additions & 0 deletions test/identity-jwt-claims-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

import {
loadIdentityVerifierConfig,
verifyIdentityJwt,
type CailIdentity,
} from "../src/index.js";
import {
createTestIdentityIssuer,
type TestClaimRecord,
} from "../src/testing.js";
import { numberFrom, stringFrom } from "../src/validation.js";

interface ClaimSchema {
type: "string" | "number";
minLength?: number;
pattern?: string;
}

interface ClaimsExample {
iss: string;
aud: string;
sub: string;
log_sub: string;
exp: number;
}

// SAFETY: this checked-in contract fixture is parsed only for the schema
// fields declared here; each test compares them against verifier behavior.
const contract = JSON.parse(
readFileSync(
new URL("../contract/identity-jwt-claims-v1.json", import.meta.url),
"utf8",
),
) as {
required: string[];
additionalProperties: boolean;
properties: Record<string, ClaimSchema>;
examples: ClaimsExample[];
};

const optionalClaims = Object.keys(contract.properties).filter(
(claim) => !contract.required.includes(claim),
);

/** Where an accepted optional claim surfaces on the verified identity. */
const IDENTITY_FIELD = new Map<string, keyof CailIdentity>([
["log_sub", "operationalSubject"],
]);

function claimOf(example: ClaimsExample, claim: string) {
return new Map(Object.entries(example)).get(claim);
}

async function verifyClaims(
example: ClaimsExample,
claims: TestClaimRecord,
): Promise<CailIdentity | null> {
const issuer = await createTestIdentityIssuer({ issuer: example.iss });
const now = example.exp - 3_600;
const token = await issuer.mintIdentityJwt({
audience: example.aud,
now,
claims,
});
const loaded = await loadIdentityVerifierConfig({
jwks: issuer.jwksJson,
issuer: example.iss,
expectedAudience: example.aud,
supportedIssuers: [example.iss],
now,
});
if (!loaded.ok) throw new Error(`invalid test config: ${loaded.reason}`);
return verifyIdentityJwt(token, loaded.config);
}

function withClaim(
example: ClaimsExample,
claim: string,
value: TestClaimRecord[string],
): TestClaimRecord {
return { ...example, [claim]: value };
}

/** Candidate values that the declared claim schema rejects. */
function violations(schema: ClaimSchema, valid: string | number | undefined) {
const values: TestClaimRecord[string][] = [];
if (schema.type === "string") {
values.push(42, "");
if (schema.pattern !== undefined) {
values.push(`${valid}0`, String(valid).toUpperCase());
}
} else {
values.push(String(valid), null);
}
return values.filter((value) => !satisfies(schema, value));
}

function satisfies(schema: ClaimSchema, value: TestClaimRecord[string]): boolean {
if (schema.type === "number") return Number.isFinite(numberFrom(value));
const text = stringFrom(value);
if (text === undefined || text.length < (schema.minLength ?? 0)) return false;
return schema.pattern === undefined || new RegExp(schema.pattern, "u").test(text);
}

describe("identity-jwt-claims-v1 contract", () => {
it("verifies each contract example and maps every declared claim", async () => {
expect([...IDENTITY_FIELD.keys()]).toEqual(optionalClaims);
for (const example of contract.examples) {
const identity = await verifyClaims(example, { ...example });
expect(identity, example.aud).not.toBeNull();
expect(identity!.subject).toBe(example.sub);
for (const claim of optionalClaims) {
expect(identity![IDENTITY_FIELD.get(claim)!], claim).toBe(
claimOf(example, claim),
);
}
}
});

it("rejects a token missing any required claim", async () => {
const [example] = contract.examples;
for (const claim of contract.required) {
await expect(
verifyClaims(example!, withClaim(example!, claim, undefined)),
claim,
).resolves.toBeNull();
}
});

it("accepts a token missing any optional claim and leaves it unmapped", async () => {
const [example] = contract.examples;
for (const claim of optionalClaims) {
const identity = await verifyClaims(
example!,
withClaim(example!, claim, undefined),
);
expect(identity, claim).not.toBeNull();
expect(identity!).not.toHaveProperty(IDENTITY_FIELD.get(claim)!);
}
});

it("rejects a value outside each declared claim schema", async () => {
const [example] = contract.examples;
for (const [claim, schema] of Object.entries(contract.properties)) {
const invalid = violations(schema, claimOf(example!, claim));
expect(invalid.length, claim).toBeGreaterThan(0);
for (const value of invalid) {
await expect(
verifyClaims(example!, withClaim(example!, claim, value)),
`${claim}=${JSON.stringify(value)}`,
).resolves.toBeNull();
}
}
});

it("accepts undeclared claims because the contract allows them", async () => {
const [example] = contract.examples;
expect(contract.additionalProperties).toBe(true);
await expect(
verifyClaims(example!, { ...example!, cail_unregistered: "ignored" }),
).resolves.toMatchObject({ subject: example!.sub });
});
});
Loading
Loading