File
Blob: tests/worker/auth/oidc.test.ts
| 1 | import type { IDToken } from "openid-client"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | |
| 4 | import { |
| 5 | appendOidcMarker, |
| 6 | decodeTxCookiePayload, |
| 7 | deriveTxCookieSecret, |
| 8 | encodeTxCookiePayload, |
| 9 | sanitizeReturnTo, |
| 10 | validateClaims, |
| 11 | validateIssuerUrl, |
| 12 | type TxCookiePayload, |
| 13 | } from "@/worker/auth/oidc"; |
| 14 | import { generateUserSlug } from "@/worker/api/public/oidc"; |
| 15 | |
| 16 | const 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 | |
| 26 | const 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 | |
| 35 | describe("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 | }); |