Skip to content
File

Blob: tests/worker/auth/oidc.test.ts

typescript129 lines
1import type { IDToken } from "openid-client";
2import { describe, expect, it } from "vitest";
3 
4import {
5 appendOidcMarker,
6 decodeTxCookiePayload,
7 deriveTxCookieSecret,
8 encodeTxCookiePayload,
9 sanitizeReturnTo,
10 validateClaims,
11 validateIssuerUrl,
12 type TxCookiePayload,
13} from "@/worker/auth/oidc";
14import { generateUserSlug } from "@/worker/api/public/oidc";
15 
16const buildPayload = (overrides: Partial<TxCookiePayload> = {}): TxCookiePayload => ({
17 state: "state",
18 nonce: "nonce",
19 codeVerifier: "verifier",
20 redirectUri: "https://example.com/api/public/oidc/callback",
21 returnTo: "/app/projects",
22 createdAt: Date.now(),
23 ...overrides,
24});
25 
26const claims = (overrides: Partial<IDToken>): IDToken =>
27 ({
28 iss: "https://tessera.test",
29 aud: "anvil-test",
30 iat: Math.floor(Date.now() / 1000),
31 exp: Math.floor(Date.now() / 1000) + 300,
32 ...overrides,
33 }) as IDToken;
34 
35describe("OIDC helpers", () => {
36 it("validates issuer URLs with loopback HTTP allowance", () => {
37 expect(validateIssuerUrl("https://auth.example.com").origin).toBe("https://auth.example.com");
38 expect(validateIssuerUrl("http://127.0.0.1:5174").origin).toBe("http://127.0.0.1:5174");
39 
40 expect(() => validateIssuerUrl(undefined)).toThrow(/TESSERA_OIDC_ISSUER/u);
41 expect(() => validateIssuerUrl("ftp://auth.example.com")).toThrow(/http\(s\)/u);
42 expect(() => validateIssuerUrl("http://auth.example.com")).toThrow(/https unless loopback/u);
43 });
44 
45 it("encodes, expires, and rejects invalid tx cookie payloads", () => {
46 const encoded = encodeTxCookiePayload(buildPayload());
47 expect(decodeTxCookiePayload(encoded)).toMatchObject({
48 state: "state",
49 nonce: "nonce",
50 returnTo: "/app/projects",
51 });
52 
53 expect(decodeTxCookiePayload(`${encoded.slice(0, -2)}xx`)).toBeNull();
54 expect(decodeTxCookiePayload("not-base64")).toBeNull();
55 expect(decodeTxCookiePayload(false)).toBeNull();
56 expect(
57 decodeTxCookiePayload(encodeTxCookiePayload(buildPayload({ createdAt: Date.now() - 10 * 60 * 1000 }))),
58 ).toBeNull();
59 });
60 
61 it("derives stable HKDF tx cookie signing keys", async () => {
62 const first = await deriveTxCookieSecret("client-secret");
63 const second = await deriveTxCookieSecret("client-secret");
64 const different = await deriveTxCookieSecret("different-secret");
65 
66 expect(first.byteLength).toBe(32);
67 expect(Array.from(new Uint8Array(second))).toEqual(Array.from(new Uint8Array(first)));
68 expect(Array.from(new Uint8Array(different))).not.toEqual(Array.from(new Uint8Array(first)));
69 });
70 
71 it("validates required verified email claims", () => {
72 expect(
73 validateClaims(
74 claims({
75 sub: "sub-1",
76 email: "USER@EXAMPLE.COM",
77 email_verified: true,
78 }),
79 ),
80 ).toMatchObject({
81 ok: true,
82 claims: {
83 sub: "sub-1",
84 email: "user@example.com",
85 },
86 });
87 
88 expect(validateClaims(undefined)).toEqual({ ok: false, code: "oidc_unverified_email" });
89 expect(validateClaims(claims({ sub: "sub-1", email: "user@example.com", email_verified: false }))).toEqual({
90 ok: false,
91 code: "oidc_unverified_email",
92 });
93 expect(validateClaims(claims({ sub: "", email: "user@example.com", email_verified: true }))).toEqual({
94 ok: false,
95 code: "oidc_unverified_email",
96 });
97 });
98 
99 it("sanitizes return paths and appends the OIDC marker", () => {
100 expect(sanitizeReturnTo("/app/projects?view=mine")).toBe("/app/projects?view=mine");
101 expect(sanitizeReturnTo("https://evil.example/app")).toBe("/app/projects");
102 expect(sanitizeReturnTo("//evil.example/app")).toBe("/app/projects");
103 expect(appendOidcMarker("/app/projects?view=mine#top")).toBe("/app/projects?view=mine&oidc=1#top");
104 });
105 
106 it("generates OwnerSlug-safe slugs from tessera claims", () => {
107 const claims = {
108 sub: "sub-1",
109 email: "First.Last@example.com",
110 email_verified: true,
111 preferredUsername: "First Last!",
112 } as const;
113 
114 expect(generateUserSlug(claims, "usr_0000000000000000ABCDEF", 0)).toBe("first-last");
115 expect(generateUserSlug(claims, "usr_0000000000000000ABCDEF", 1)).toBe("first-last-ABCDEF");
116 expect(
117 generateUserSlug(
118 {
119 sub: "sub-1",
120 email: "@example.com",
121 email_verified: true,
122 },
123 "usr_0000000000000000ABCDEF",
124 0,
125 ),
126 ).toBe("usr-ABCDEF");
127 });
128});