File
Blob: tests/worker/control/auth/primitives.test.ts
| 1 | import { describe, expect, it } from "vitest"; |
| 2 | |
| 3 | import { |
| 4 | getOidcTransactionCookie, |
| 5 | getSessionCookie, |
| 6 | oidcTransactionTtlMs, |
| 7 | setOidcTransactionCookie, |
| 8 | setSessionCookie, |
| 9 | } from "@/worker/auth/cookies"; |
| 10 | import { isValidHostLabel, generateHostLabel } from "@/worker/auth/host-labels"; |
| 11 | import { HOST_WORDS } from "@/worker/auth/host-wordlist"; |
| 12 | import { digestPat, generatePat, normalizeScopes, parseBasicAuth, parsePat } from "@/worker/auth/pats"; |
| 13 | import { hasDavScope } from "@/worker/auth/scopes"; |
| 14 | import type { AppContext } from "@/worker/types"; |
| 15 | |
| 16 | function contextWithEnv(cookie?: string): { c: AppContext; responseHeaders: Headers } { |
| 17 | const headers = new Headers(); |
| 18 | if (cookie) headers.set("cookie", cookie); |
| 19 | const c = { |
| 20 | env: { |
| 21 | TESSERA_OIDC_CLIENT_SECRET: "oidc-test-secret", |
| 22 | DAB_SESSION_SECRET: "session-test-secret", |
| 23 | }, |
| 24 | req: { |
| 25 | raw: new Request("https://dav.example.com/", { headers }), |
| 26 | }, |
| 27 | header(name: string, value: string, options?: { append?: boolean }) { |
| 28 | if (options?.append) headers.append(name, value); |
| 29 | else headers.set(name, value); |
| 30 | }, |
| 31 | } as unknown as AppContext; |
| 32 | return { c, responseHeaders: headers }; |
| 33 | } |
| 34 | |
| 35 | function setCookie(responseHeaders: Headers): string { |
| 36 | const value = responseHeaders.get("set-cookie") ?? ""; |
| 37 | if (value) return value; |
| 38 | throw new Error("missing Set-Cookie"); |
| 39 | } |
| 40 | |
| 41 | function cookiePair(header: string): string { |
| 42 | return header.split(";")[0] ?? header; |
| 43 | } |
| 44 | |
| 45 | describe("auth primitives", () => { |
| 46 | it("signs and verifies OIDC transaction cookies with host cookie attributes", async () => { |
| 47 | const ctx = contextWithEnv(); |
| 48 | const nowMs = Date.now(); |
| 49 | await setOidcTransactionCookie(ctx.c, { |
| 50 | state: "state", |
| 51 | nonce: "nonce", |
| 52 | codeVerifier: "verifier", |
| 53 | returnTo: "/after", |
| 54 | createdAtMs: nowMs, |
| 55 | expiresAtMs: nowMs + oidcTransactionTtlMs, |
| 56 | }); |
| 57 | |
| 58 | const header = setCookie(ctx.responseHeaders); |
| 59 | expect(header).toContain("__Host-dab_oidc_tx="); |
| 60 | expect(header).toContain("HttpOnly"); |
| 61 | expect(header).toContain("Secure"); |
| 62 | expect(header).toContain("SameSite=Lax"); |
| 63 | expect(header).toContain("Path=/"); |
| 64 | expect(header).not.toContain("Domain="); |
| 65 | |
| 66 | await expect(getOidcTransactionCookie(contextWithEnv(cookiePair(header)).c)).resolves.toMatchObject({ |
| 67 | state: "state", |
| 68 | nonce: "nonce", |
| 69 | codeVerifier: "verifier", |
| 70 | returnTo: "/after", |
| 71 | }); |
| 72 | }); |
| 73 | |
| 74 | it("signs and verifies API session cookies with a distinct key context", async () => { |
| 75 | const ctx = contextWithEnv(); |
| 76 | const nowMs = Date.now(); |
| 77 | await setSessionCookie(ctx.c, { |
| 78 | subjectId: "sub", |
| 79 | storageId: "stg_123", |
| 80 | sessionId: "ses_123", |
| 81 | createdAtMs: nowMs, |
| 82 | expiresAtMs: nowMs + 1000, |
| 83 | }); |
| 84 | |
| 85 | const header = setCookie(ctx.responseHeaders); |
| 86 | expect(header).toContain("__Host-dab_session="); |
| 87 | await expect(getSessionCookie(contextWithEnv(cookiePair(header)).c)).resolves.toMatchObject({ |
| 88 | subjectId: "sub", |
| 89 | storageId: "stg_123", |
| 90 | sessionId: "ses_123", |
| 91 | }); |
| 92 | await expect(getOidcTransactionCookie(contextWithEnv(cookiePair(header)).c)).resolves.toBeNull(); |
| 93 | }); |
| 94 | |
| 95 | it("generates and validates five-word host labels from the checked-in wordlist", () => { |
| 96 | expect(HOST_WORDS).toHaveLength(8192); |
| 97 | expect(new Set(HOST_WORDS).size).toBe(8192); |
| 98 | expect(HOST_WORDS.every((word) => /^[a-z]{3,8}$/.test(word))).toBe(true); |
| 99 | |
| 100 | for (let i = 0; i < 50; i += 1) { |
| 101 | const label = generateHostLabel(); |
| 102 | expect(label).toMatch(/^[a-z]+(-[a-z]+){4}$/); |
| 103 | expect(label.length).toBeLessThanOrEqual(63); |
| 104 | expect(isValidHostLabel(label)).toBe(true); |
| 105 | } |
| 106 | |
| 107 | expect(isValidHostLabel("river-copper-lantern-velvet-maple")).toBe(true); |
| 108 | expect(isValidHostLabel("not-enough-words")).toBe(false); |
| 109 | expect(isValidHostLabel("river-copper-lantern-velvet-unknownword")).toBe(false); |
| 110 | }); |
| 111 | |
| 112 | it("parses PAT grammar, digests tokens, normalizes scopes, and parses Basic auth", async () => { |
| 113 | const generated = await generatePat(); |
| 114 | const parsed = parsePat(generated.token); |
| 115 | expect(parsed?.id).toBe(generated.id); |
| 116 | expect(generated.id).toMatch(/^dab_pat_[0-9a-f]{24}$/); |
| 117 | expect(generated.token).toMatch(/^dab_pat_[0-9a-f]{24}_[a-z2-7]{52}$/); |
| 118 | await expect(digestPat(generated.token)).resolves.toBe(generated.tokenDigest); |
| 119 | |
| 120 | expect(parsePat("dab_pat_short_bad")).toBeNull(); |
| 121 | expect(normalizeScopes(["files.full", "dav:carddav:read"])).toEqual([ |
| 122 | "dav:carddav:read", |
| 123 | "dav:files:read", |
| 124 | "dav:files:write", |
| 125 | ]); |
| 126 | expect(normalizeScopes(["unknown.scope"])).toBeNull(); |
| 127 | |
| 128 | const auth = `Basic ${btoa(`user:${generated.token}`)}`; |
| 129 | expect(parseBasicAuth(auth)).toEqual({ username: "user", password: generated.token }); |
| 130 | }); |
| 131 | |
| 132 | it("treats write DAV scopes as implying read for authorization checks", () => { |
| 133 | expect(hasDavScope(["dav:files:write"], "dav:files:read")).toBe(true); |
| 134 | expect(hasDavScope(["dav:caldav:write"], "dav:caldav:read")).toBe(true); |
| 135 | expect(hasDavScope(["dav:carddav:write"], "dav:carddav:read")).toBe(true); |
| 136 | expect(hasDavScope(["dav:files:read"], "dav:files:write")).toBe(false); |
| 137 | }); |
| 138 | }); |