Skip to content
File

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

typescript171 lines
1import { afterEach, describe, expect, it } from "vitest";
2import {
3 __test,
4 appendOidcMarker,
5 decodeTxCookie,
6 encodeTxCookie,
7 oidcErrorContext,
8 sanitizeReturnTo,
9 validateIssuerUrl,
10 type OidcConfigEnv,
11 type TxCookiePayload,
12} from "@/worker/lib/oidc";
13 
14const env: OidcConfigEnv = {
15 TESSERA_OIDC_ISSUER: "https://tessera.test",
16 TESSERA_OIDC_CLIENT_ID: "bland-test",
17 TESSERA_OIDC_CLIENT_SECRET: "test-secret-deterministic",
18};
19 
20afterEach(() => {
21 __test.clear();
22});
23 
24describe("sanitizeReturnTo", () => {
25 it("returns / for missing or empty input", () => {
26 expect(sanitizeReturnTo(undefined)).toBe("/");
27 expect(sanitizeReturnTo("")).toBe("/");
28 expect(sanitizeReturnTo(" ")).toBe("/");
29 });
30 
31 it("rejects non-absolute paths", () => {
32 expect(sanitizeReturnTo("workspaces")).toBe("/");
33 expect(sanitizeReturnTo("./inner")).toBe("/");
34 });
35 
36 it("rejects protocol-relative and scheme-bearing urls", () => {
37 expect(sanitizeReturnTo("//evil.example")).toBe("/");
38 expect(sanitizeReturnTo("javascript:alert(1)")).toBe("/");
39 expect(sanitizeReturnTo("https://evil.example/path")).toBe("/");
40 expect(sanitizeReturnTo("/path?next=https://evil.example")).toBe("/");
41 });
42 
43 it("rejects backslashes", () => {
44 expect(sanitizeReturnTo("/path\\back")).toBe("/");
45 });
46 
47 it("rejects control characters", () => {
48 expect(sanitizeReturnTo("/path\x00x")).toBe("/");
49 expect(sanitizeReturnTo("/path\x1fx")).toBe("/");
50 expect(sanitizeReturnTo("/path\x7fx")).toBe("/");
51 });
52 
53 it("preserves a valid path including query and fragment", () => {
54 expect(sanitizeReturnTo("/invite/abc?accept=1")).toBe("/invite/abc?accept=1");
55 expect(sanitizeReturnTo("/workspaces/foo#bar")).toBe("/workspaces/foo#bar");
56 });
57});
58 
59describe("validateIssuerUrl", () => {
60 it("requires https unless loopback", () => {
61 expect(() => validateIssuerUrl("http://example.com")).toThrow();
62 expect(validateIssuerUrl("https://example.com").origin).toBe("https://example.com");
63 expect(validateIssuerUrl("http://localhost:8787").origin).toBe("http://localhost:8787");
64 expect(validateIssuerUrl("http://127.0.0.1:8787").origin).toBe("http://127.0.0.1:8787");
65 });
66 
67 it("rejects missing values", () => {
68 expect(() => validateIssuerUrl(undefined)).toThrow();
69 expect(() => validateIssuerUrl("")).toThrow();
70 });
71 
72 it("rejects unknown protocols", () => {
73 expect(() => validateIssuerUrl("ftp://example.com")).toThrow();
74 });
75 
76 it("strips a trailing slash", () => {
77 const url = validateIssuerUrl("https://example.com/");
78 expect(url.toString()).toBe("https://example.com/");
79 });
80});
81 
82describe("oidcErrorContext", () => {
83 it("keeps the openid-client cause chain and issuer mismatch details", () => {
84 const configuredIssuer = "http://127.0.0.1:8787/";
85 const discoveredIssuer = "http://localhost:8787/";
86 const cause = new Error("unexpected JSON attribute value encountered");
87 Object.assign(cause, {
88 code: "OAUTH_JSON_ATTRIBUTE_COMPARISON_FAILED",
89 cause: {
90 expected: configuredIssuer,
91 body: { issuer: discoveredIssuer },
92 attribute: "issuer",
93 },
94 });
95 const err = new Error("something went wrong", { cause });
96 
97 expect(oidcErrorContext(err, { ...env, TESSERA_OIDC_ISSUER: configuredIssuer })).toMatchObject({
98 configuredIssuer,
99 errorName: "Error",
100 errorMessage: "something went wrong",
101 causeErrorName: "Error",
102 causeErrorMessage: "unexpected JSON attribute value encountered",
103 causeErrorCode: "OAUTH_JSON_ATTRIBUTE_COMPARISON_FAILED",
104 expectedIssuer: configuredIssuer,
105 discoveredIssuer,
106 });
107 });
108});
109 
110describe("appendOidcMarker", () => {
111 it("appends oidc=1 to a bare path", () => {
112 expect(appendOidcMarker("/")).toBe("/?oidc=1");
113 expect(appendOidcMarker("/invite/abc")).toBe("/invite/abc?oidc=1");
114 });
115 
116 it("preserves existing query string", () => {
117 expect(appendOidcMarker("/invite/abc?accept=1")).toBe("/invite/abc?accept=1&oidc=1");
118 });
119 
120 it("preserves a hash", () => {
121 expect(appendOidcMarker("/page#anchor")).toBe("/page?oidc=1#anchor");
122 expect(appendOidcMarker("/page?x=1#anchor")).toBe("/page?x=1&oidc=1#anchor");
123 });
124});
125 
126describe("tx cookie sign/verify", () => {
127 const payload: TxCookiePayload = {
128 state: "state-value",
129 nonce: "nonce-value",
130 codeVerifier: "code-verifier-1234567890",
131 redirectUri: "https://bland.test/api/v1/oidc/callback",
132 returnTo: "/",
133 createdAt: Date.now(),
134 };
135 
136 it("round-trips a payload", async () => {
137 const cookie = await encodeTxCookie(env, payload);
138 const decoded = await decodeTxCookie(env, cookie);
139 expect(decoded).toEqual(payload);
140 });
141 
142 it("rejects a tampered cookie", async () => {
143 const cookie = await encodeTxCookie(env, payload);
144 // Flip a character in the payload portion
145 const idx = cookie.indexOf(".");
146 const tampered = `${cookie.slice(0, 1)}X${cookie.slice(2, idx)}${cookie.slice(idx)}`;
147 const decoded = await decodeTxCookie(env, tampered);
148 expect(decoded).toBeNull();
149 });
150 
151 it("rejects a cookie signed with a different secret", async () => {
152 const cookie = await encodeTxCookie(env, payload);
153 const altEnv: OidcConfigEnv = { ...env, TESSERA_OIDC_CLIENT_SECRET: "different-secret" };
154 const decoded = await decodeTxCookie(altEnv, cookie);
155 expect(decoded).toBeNull();
156 });
157 
158 it("rejects an expired cookie", async () => {
159 const old: TxCookiePayload = { ...payload, createdAt: Date.now() - 10 * 60 * 1000 };
160 const cookie = await encodeTxCookie(env, old);
161 const decoded = await decodeTxCookie(env, cookie);
162 expect(decoded).toBeNull();
163 });
164 
165 it("returns null for missing or malformed input", async () => {
166 expect(await decodeTxCookie(env, undefined)).toBeNull();
167 expect(await decodeTxCookie(env, "")).toBeNull();
168 expect(await decodeTxCookie(env, "no-dot")).toBeNull();
169 });
170});