File
Blob: src/worker/auth/pat.ts
| 1 | import { createDb, type Db } from "@/worker/db/d1/client"; |
| 2 | import { |
| 3 | findPatByPrefix, |
| 4 | findPatGrantForNamespace, |
| 5 | findPatGrantForRepo, |
| 6 | type PatGrantLevel, |
| 7 | } from "@/worker/db/d1/dal/tokens"; |
| 8 | import { findMembership, findNamespaceBySlug } from "@/worker/db/d1/dal/namespaces"; |
| 9 | |
| 10 | // Parse/generate/hash/validate helpers and `verifyPat` share a single file |
| 11 | // so the management and verification surfaces stay on the same parsing and |
| 12 | // hashing rules. |
| 13 | |
| 14 | const PAT_PREFIX_PUBLIC = "goc_"; |
| 15 | const PAT_PREFIX_HEX_LENGTH = 8; |
| 16 | // 32-char base32 secret, 5 bits per char => 160 bits of entropy. |
| 17 | const PAT_SECRET_BASE32_LENGTH = 32; |
| 18 | const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; |
| 19 | |
| 20 | const PAT_PATTERN = /^goc_([0-9a-f]{8})_([abcdefghijklmnopqrstuvwxyz234567]{32})$/; |
| 21 | |
| 22 | export function formatPatPublicPrefix(hexPrefix: string): string { |
| 23 | return `${PAT_PREFIX_PUBLIC}${hexPrefix}`; |
| 24 | } |
| 25 | |
| 26 | function bytesToHex(bytes: Uint8Array): string { |
| 27 | let out = ""; |
| 28 | for (let i = 0; i < bytes.length; i += 1) { |
| 29 | out += bytes[i]!.toString(16).padStart(2, "0"); |
| 30 | } |
| 31 | return out; |
| 32 | } |
| 33 | |
| 34 | function bytesToBase32(bytes: Uint8Array): string { |
| 35 | let bits = 0; |
| 36 | let value = 0; |
| 37 | let out = ""; |
| 38 | for (let i = 0; i < bytes.length; i += 1) { |
| 39 | value = (value << 8) | bytes[i]!; |
| 40 | bits += 8; |
| 41 | while (bits >= 5) { |
| 42 | bits -= 5; |
| 43 | out += BASE32_ALPHABET[(value >>> bits) & 0x1f]; |
| 44 | } |
| 45 | } |
| 46 | if (bits > 0) { |
| 47 | out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]; |
| 48 | } |
| 49 | return out; |
| 50 | } |
| 51 | |
| 52 | export type GeneratedPat = { |
| 53 | plaintext: string; |
| 54 | publicPrefix: string; |
| 55 | }; |
| 56 | |
| 57 | // Mint a fresh PAT plaintext + its public prefix. The hex prefix lookups |
| 58 | // the row; the secret is what we hash. The full plaintext (prefix + |
| 59 | // secret) is what callers must hash via `hashPatPlaintext`. |
| 60 | export function generatePatPlaintext(): GeneratedPat { |
| 61 | const prefixBytes = new Uint8Array(PAT_PREFIX_HEX_LENGTH / 2); |
| 62 | crypto.getRandomValues(prefixBytes); |
| 63 | // base32 needs ceil(secretLength * 5 / 8) bytes of entropy. 32 chars * 5 bits = 160 bits = 20 bytes. |
| 64 | const secretBytes = new Uint8Array(20); |
| 65 | crypto.getRandomValues(secretBytes); |
| 66 | const hexPrefix = bytesToHex(prefixBytes); |
| 67 | const secret = bytesToBase32(secretBytes).slice(0, PAT_SECRET_BASE32_LENGTH); |
| 68 | const publicPrefix = formatPatPublicPrefix(hexPrefix); |
| 69 | return { plaintext: `${publicPrefix}_${secret}`, publicPrefix }; |
| 70 | } |
| 71 | |
| 72 | export type ParsePatResult = |
| 73 | | { ok: true; publicPrefix: string; secret: string } |
| 74 | | { ok: false; reason: "malformed" }; |
| 75 | |
| 76 | // Validate basic shape and split a plaintext into its public prefix + secret |
| 77 | // segments. We never compare segments; the verifier hashes the full |
| 78 | // plaintext and compares against the stored hash. |
| 79 | export function parsePatPlaintext(plaintext: string): ParsePatResult { |
| 80 | if (typeof plaintext !== "string") return { ok: false, reason: "malformed" }; |
| 81 | const match = PAT_PATTERN.exec(plaintext); |
| 82 | if (!match) return { ok: false, reason: "malformed" }; |
| 83 | return { |
| 84 | ok: true, |
| 85 | publicPrefix: formatPatPublicPrefix(match[1]!), |
| 86 | secret: match[2]!, |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | export async function hashPatPlaintext(plaintext: string): Promise<string> { |
| 91 | const data = new TextEncoder().encode(plaintext); |
| 92 | const digest = await crypto.subtle.digest("SHA-256", data); |
| 93 | return bytesToHex(new Uint8Array(digest)); |
| 94 | } |
| 95 | |
| 96 | const PAT_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9 _-]{0,38}[A-Za-z0-9])?$/; |
| 97 | |
| 98 | export type PatNameValidation = |
| 99 | | { ok: true; name: string } |
| 100 | | { ok: false; reason: "format" | "length" }; |
| 101 | |
| 102 | export function validatePatName(input: string): PatNameValidation { |
| 103 | if (typeof input !== "string") return { ok: false, reason: "format" }; |
| 104 | const trimmed = input.trim(); |
| 105 | if (trimmed.length === 0 || trimmed.length > 40) return { ok: false, reason: "length" }; |
| 106 | if (!PAT_NAME_PATTERN.test(trimmed)) return { ok: false, reason: "format" }; |
| 107 | return { ok: true, name: trimmed }; |
| 108 | } |
| 109 | |
| 110 | export type PatVerifyOk = { |
| 111 | ok: true; |
| 112 | patId: string; |
| 113 | userId: string; |
| 114 | namespaceId: string; |
| 115 | repositoryId?: string; |
| 116 | // `level === "push"` authorizes receive-pack; any present grant |
| 117 | // authorizes fetch/clone because `push` includes `pull` by construction |
| 118 | // (DB CHECK constraint on `pat_*_grants.level`). |
| 119 | level: PatGrantLevel; |
| 120 | // Loaded value at verification time so callers throttle the write |
| 121 | // decision in Worker memory; see `shouldTouchPatLastUsedAt`. |
| 122 | lastUsedAt: number | null; |
| 123 | }; |
| 124 | |
| 125 | export type PatVerifyError = |
| 126 | | { ok: false; reason: "malformed" } |
| 127 | | { ok: false; reason: "username-mismatch" } |
| 128 | | { ok: false; reason: "token-not-found" } |
| 129 | | { ok: false; reason: "token-revoked" } |
| 130 | | { ok: false; reason: "token-expired" } |
| 131 | | { ok: false; reason: "grant-missing" }; |
| 132 | |
| 133 | export type PatVerifyResult = PatVerifyOk | PatVerifyError; |
| 134 | |
| 135 | async function constantTimeEquals(a: string, b: string): Promise<boolean> { |
| 136 | if (a.length !== b.length) return false; |
| 137 | const encoder = new TextEncoder(); |
| 138 | const aBytes = encoder.encode(a); |
| 139 | const bBytes = encoder.encode(b); |
| 140 | // Prefer Cloudflare's timingSafeEqual when present; fall back to a tight |
| 141 | // JS XOR so this still works in non-Workers vitest unit-only contexts. |
| 142 | type CfSubtle = SubtleCrypto & { |
| 143 | timingSafeEqual?: ( |
| 144 | left: ArrayBuffer | ArrayBufferView, |
| 145 | right: ArrayBuffer | ArrayBufferView |
| 146 | ) => boolean; |
| 147 | }; |
| 148 | const subtle = crypto.subtle as CfSubtle; |
| 149 | if (typeof subtle.timingSafeEqual === "function") { |
| 150 | return subtle.timingSafeEqual(aBytes, bBytes); |
| 151 | } |
| 152 | let result = 0; |
| 153 | for (let i = 0; i < aBytes.length; i += 1) { |
| 154 | result |= aBytes[i]! ^ bBytes[i]!; |
| 155 | } |
| 156 | return result === 0; |
| 157 | } |
| 158 | |
| 159 | export type VerifyPatArgs = { |
| 160 | username: string; |
| 161 | plaintext: string; |
| 162 | // Resolved route context supplies these ids before verification. The |
| 163 | // verifier does not infer repository identity from storage names. |
| 164 | namespaceId?: string; |
| 165 | repositoryId?: string; |
| 166 | now?: number; |
| 167 | db?: Db; |
| 168 | }; |
| 169 | |
| 170 | // Verify a PAT against the stored hash and grants. Returns the user/scope |
| 171 | // when the token is currently valid for the requested namespace + repo |
| 172 | // pair, otherwise a tagged failure that the caller maps to an HTTP |
| 173 | // status (typically 401 for malformed/not-found, 403 for grant-missing). |
| 174 | export async function verifyPat(env: Env, args: VerifyPatArgs): Promise<PatVerifyResult> { |
| 175 | const now = args.now ?? Date.now(); |
| 176 | const parsed = parsePatPlaintext(args.plaintext); |
| 177 | if (!parsed.ok) return { ok: false, reason: "malformed" }; |
| 178 | |
| 179 | const db = args.db ?? createDb(env.DB); |
| 180 | |
| 181 | const pat = await findPatByPrefix(db, parsed.publicPrefix); |
| 182 | if (!pat) return { ok: false, reason: "token-not-found" }; |
| 183 | if (pat.revokedAt !== null) return { ok: false, reason: "token-revoked" }; |
| 184 | if (pat.expiresAt !== null && pat.expiresAt <= now) return { ok: false, reason: "token-expired" }; |
| 185 | |
| 186 | const expectedHash = pat.hash; |
| 187 | const presentedHash = await hashPatPlaintext(args.plaintext); |
| 188 | if (!(await constantTimeEquals(expectedHash, presentedHash))) { |
| 189 | return { ok: false, reason: "token-not-found" }; |
| 190 | } |
| 191 | |
| 192 | // Resolve namespace either directly via id or by username; both must align |
| 193 | // for the username-as-namespace-slug Git authentication contract. |
| 194 | let resolvedNamespaceId: string | undefined = args.namespaceId; |
| 195 | let resolvedRepositoryId: string | undefined = args.repositoryId; |
| 196 | if (!resolvedNamespaceId) { |
| 197 | const namespace = await findNamespaceBySlug(db, args.username); |
| 198 | if (!namespace) return { ok: false, reason: "username-mismatch" }; |
| 199 | resolvedNamespaceId = namespace.id; |
| 200 | } else { |
| 201 | const namespace = await findNamespaceBySlug(db, args.username); |
| 202 | if (!namespace || namespace.id !== resolvedNamespaceId) { |
| 203 | return { ok: false, reason: "username-mismatch" }; |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // Repo grant takes precedence; namespace grant covers remaining repos. |
| 208 | if (resolvedRepositoryId) { |
| 209 | const repoGrant = await findPatGrantForRepo(db, pat.id, resolvedRepositoryId); |
| 210 | if (repoGrant) { |
| 211 | return { |
| 212 | ok: true, |
| 213 | patId: pat.id, |
| 214 | userId: pat.userId, |
| 215 | namespaceId: resolvedNamespaceId, |
| 216 | repositoryId: resolvedRepositoryId, |
| 217 | level: repoGrant.level, |
| 218 | lastUsedAt: pat.lastUsedAt, |
| 219 | }; |
| 220 | } |
| 221 | } |
| 222 | const namespaceGrant = await findPatGrantForNamespace(db, pat.id, resolvedNamespaceId); |
| 223 | if (namespaceGrant) { |
| 224 | return { |
| 225 | ok: true, |
| 226 | patId: pat.id, |
| 227 | userId: pat.userId, |
| 228 | namespaceId: resolvedNamespaceId, |
| 229 | repositoryId: resolvedRepositoryId, |
| 230 | level: namespaceGrant.level, |
| 231 | lastUsedAt: pat.lastUsedAt, |
| 232 | }; |
| 233 | } |
| 234 | |
| 235 | return { ok: false, reason: "grant-missing" }; |
| 236 | } |
| 237 | |
| 238 | // Writes always update; reads only when the prior value is older than this |
| 239 | // window (or null) so D1 is not written on every clone. |
| 240 | export const PAT_LAST_USED_READ_THROTTLE_MS = 15 * 60 * 1000; |
| 241 | |
| 242 | export type PatLastUsedOp = "read" | "write"; |
| 243 | |
| 244 | export function shouldTouchPatLastUsedAt( |
| 245 | lastUsedAt: number | null, |
| 246 | op: PatLastUsedOp, |
| 247 | now: number = Date.now() |
| 248 | ): boolean { |
| 249 | if (op === "write") return true; |
| 250 | if (lastUsedAt === null) return true; |
| 251 | return now - lastUsedAt >= PAT_LAST_USED_READ_THROTTLE_MS; |
| 252 | } |
| 253 | |
| 254 | // Internal: callers in management endpoints need to confirm the |
| 255 | // authenticated viewer is a member of the namespace they want to scope a |
| 256 | // PAT to. Wraps the DAL composite-key lookup. |
| 257 | export async function viewerIsNamespaceMember( |
| 258 | db: Db, |
| 259 | userId: string, |
| 260 | namespaceId: string |
| 261 | ): Promise<boolean> { |
| 262 | return (await findMembership(db, namespaceId, userId)) !== undefined; |
| 263 | } |