File
Blob: tests/worker/lib/oidc.test.ts
| 1 | import { afterEach, describe, expect, it } from "vitest"; |
| 2 | import { |
| 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 | |
| 14 | const 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 | |
| 20 | afterEach(() => { |
| 21 | __test.clear(); |
| 22 | }); |
| 23 | |
| 24 | describe("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 | |
| 59 | describe("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 | |
| 82 | describe("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 | |
| 110 | describe("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 | |
| 126 | describe("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 | }); |