Skip to content
File

Blob: tests/worker/control/auth/primitives.test.ts

typescript139 lines
1import { describe, expect, it } from "vitest";
2 
3import {
4 getOidcTransactionCookie,
5 getSessionCookie,
6 oidcTransactionTtlMs,
7 setOidcTransactionCookie,
8 setSessionCookie,
9} from "@/worker/auth/cookies";
10import { isValidHostLabel, generateHostLabel } from "@/worker/auth/host-labels";
11import { HOST_WORDS } from "@/worker/auth/host-wordlist";
12import { digestPat, generatePat, normalizeScopes, parseBasicAuth, parsePat } from "@/worker/auth/pats";
13import { hasDavScope } from "@/worker/auth/scopes";
14import type { AppContext } from "@/worker/types";
15 
16function 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 
35function 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 
41function cookiePair(header: string): string {
42 return header.split(";")[0] ?? header;
43}
44 
45describe("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});