Skip to content
File

Blob: src/worker/auth/pats.ts

typescript97 lines
1import { base32FromBytes, randomBytes, randomHex } from "@/worker/auth/bytes";
2import { sha256Hex } from "@/worker/auth/crypto";
3import type { DavScope } from "@/worker/db/types";
4 
5export const PAT_PREFIX = "dab_pat_";
6export const PAT_RE = /^dab_pat_([0-9a-f]{24,})_([a-z2-7]{52,})$/;
7 
8export const allDavScopes = [
9 "dav:files:read",
10 "dav:files:write",
11 "dav:caldav:read",
12 "dav:caldav:write",
13 "dav:carddav:read",
14 "dav:carddav:write",
15] as const satisfies readonly DavScope[];
16 
17export const patScopePresets: Record<string, DavScope[]> = {
18 "files.readonly": ["dav:files:read"],
19 "files.full": ["dav:files:read", "dav:files:write"],
20 "caldav.readonly": ["dav:caldav:read"],
21 "caldav.full": ["dav:caldav:read", "dav:caldav:write"],
22 "carddav.readonly": ["dav:carddav:read"],
23 "carddav.full": ["dav:carddav:read", "dav:carddav:write"],
24 "dav.full": [...allDavScopes],
25};
26 
27export interface ParsedPat {
28 id: string;
29 publicId: string;
30 secret: string;
31}
32 
33export interface GeneratedPat {
34 id: string;
35 token: string;
36 tokenDigest: string;
37}
38 
39export function parsePat(token: string): ParsedPat | null {
40 const match = PAT_RE.exec(token);
41 if (!match) return null;
42 const [, publicId, secret] = match;
43 return { id: `${PAT_PREFIX}${publicId}`, publicId, secret };
44}
45 
46export async function generatePat(): Promise<GeneratedPat> {
47 const publicId = randomHex(12);
48 const secret = base32FromBytes(randomBytes(32));
49 const id = `${PAT_PREFIX}${publicId}`;
50 const token = `${id}_${secret}`;
51 return { id, token, tokenDigest: await sha256Hex(token) };
52}
53 
54export async function digestPat(token: string): Promise<string | null> {
55 if (!parsePat(token)) return null;
56 return await sha256Hex(token);
57}
58 
59export function normalizeScopes(input: readonly string[]): DavScope[] | null {
60 const expanded = new Set<DavScope>();
61 
62 for (const scope of input) {
63 const preset = patScopePresets[scope];
64 if (preset) {
65 for (const value of preset) expanded.add(value);
66 continue;
67 }
68 
69 if ((allDavScopes as readonly string[]).includes(scope)) {
70 expanded.add(scope as DavScope);
71 continue;
72 }
73 
74 return null;
75 }
76 
77 return [...expanded].sort();
78}
79 
80export function parseBasicAuth(header: string | null): { username: string; password: string } | null {
81 if (!header) return null;
82 const [scheme, encoded, extra] = header.split(/\s+/);
83 if (!scheme || !encoded || extra !== undefined || scheme.toLowerCase() !== "basic") return null;
84 
85 try {
86 const decoded = atob(encoded);
87 const separator = decoded.indexOf(":");
88 if (separator === -1) return null;
89 return {
90 username: decoded.slice(0, separator),
91 password: decoded.slice(separator + 1),
92 };
93 } catch {
94 return null;
95 }
96}