|
| 1 | +const SCOPES = new Set(["sites:create", "sites:read", "sites:import", "operations:read"]) |
| 2 | +const DEFAULT_AUDIT_LIMIT = 10_000 |
| 3 | +const UNKNOWN_AUDIT_BUCKET_MS = 5 * 60_000 |
| 4 | + |
| 5 | +export interface PrincipalCredentialPolicy { |
| 6 | + principal: string |
| 7 | + scopes: string[] |
| 8 | + expiresAt: string |
| 9 | + maxSites: number |
| 10 | + sites?: string[] |
| 11 | +} |
| 12 | +export interface PrincipalCredentialVersion extends PrincipalCredentialPolicy { |
| 13 | + credentialId: string |
| 14 | + version: string |
| 15 | + digest: string |
| 16 | +} |
| 17 | +export interface AuthenticatedPrincipal extends PrincipalCredentialPolicy { |
| 18 | + credentialId: string |
| 19 | + version: string |
| 20 | +} |
| 21 | +export interface CredentialAuditEvent { |
| 22 | + id: number |
| 23 | + at: number |
| 24 | + kind: "registered" | "revoked" | "authenticated" | "denied" |
| 25 | + principal: string | null |
| 26 | + credentialId: string | null |
| 27 | + version: string | null |
| 28 | + reason: "ok" | "unknown" | "expired" | "revoked" | "scope" | "site" |
| 29 | +} |
| 30 | +export type CredentialAuthorizationDecision = |
| 31 | + | { status: "not-found" } |
| 32 | + | { status: "allowed"; principal: AuthenticatedPrincipal } |
| 33 | + | { status: "denied"; reason: "expired" | "revoked" | "scope" | "site"; principal: string; credentialId: string; version: string } |
| 34 | + |
| 35 | +/** Durable bearer policy storage. Bearers are hashed before they cross this boundary. */ |
| 36 | +export class D1PrincipalCredentialRepository { |
| 37 | + constructor(private readonly db: D1Database, private readonly auditLimit = DEFAULT_AUDIT_LIMIT) { |
| 38 | + if (!Number.isSafeInteger(auditLimit) || auditLimit < 1 || auditLimit > DEFAULT_AUDIT_LIMIT) throw new Error("Credential audit limit is invalid.") |
| 39 | + } |
| 40 | + |
| 41 | + async initialize(): Promise<void> { |
| 42 | + await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_principal_credentials (credential_id TEXT NOT NULL, version TEXT NOT NULL, digest TEXT NOT NULL UNIQUE, principal TEXT NOT NULL, scopes TEXT NOT NULL, expires_at INTEGER NOT NULL, max_sites INTEGER NOT NULL, sites TEXT, revoked_at INTEGER, created_at INTEGER NOT NULL, PRIMARY KEY (credential_id, version))").run() |
| 43 | + await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_principal_credential_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('registered','revoked','authenticated','denied')), principal TEXT, credential_id TEXT, version TEXT, reason TEXT NOT NULL CHECK (reason IN ('ok','unknown','expired','revoked','scope','site')), dedupe_key TEXT UNIQUE)").run() |
| 44 | + } |
| 45 | + |
| 46 | + async register(input: PrincipalCredentialVersion): Promise<void> { |
| 47 | + validate(input) |
| 48 | + await this.initialize() |
| 49 | + const expiresAt = Date.parse(input.expiresAt) |
| 50 | + const scopes = JSON.stringify(input.scopes) |
| 51 | + const sites = input.sites ? JSON.stringify(input.sites) : null |
| 52 | + const existing = await this.db.prepare("SELECT digest, principal, scopes, expires_at, max_sites, sites FROM wp_codebox_principal_credentials WHERE credential_id = ? AND version = ?").bind(input.credentialId, input.version).first<Record<string, unknown>>() |
| 53 | + if (existing) { |
| 54 | + if (sameCredential(existing, input, scopes, sites, expiresAt)) return |
| 55 | + throw new Error("Credential version is already registered with different identity or policy.") |
| 56 | + } |
| 57 | + try { |
| 58 | + const now = Date.now() |
| 59 | + await this.db.batch([ |
| 60 | + this.db.prepare("INSERT INTO wp_codebox_principal_credentials (credential_id, version, digest, principal, scopes, expires_at, max_sites, sites, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)").bind(input.credentialId, input.version, input.digest, input.principal, scopes, expiresAt, input.maxSites, sites, now), |
| 61 | + this.auditStatement("registered", input.principal, input.credentialId, input.version, "ok", now), |
| 62 | + this.trimAuditStatement(), |
| 63 | + ]) |
| 64 | + } catch (error) { |
| 65 | + if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error |
| 66 | + const raced = await this.db.prepare("SELECT digest, principal, scopes, expires_at, max_sites, sites FROM wp_codebox_principal_credentials WHERE credential_id = ? AND version = ?").bind(input.credentialId, input.version).first<Record<string, unknown>>() |
| 67 | + if (raced && sameCredential(raced, input, scopes, sites, expiresAt)) return |
| 68 | + throw new Error("Credential digest is already registered.") |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + async revoke(credentialId: string, version: string, now = Date.now()): Promise<boolean> { |
| 73 | + if (!identifier(credentialId) || !identifier(version) || !Number.isSafeInteger(now) || now < 1) throw new Error("Credential revocation request is invalid.") |
| 74 | + await this.initialize() |
| 75 | + const row = await this.db.prepare("SELECT principal FROM wp_codebox_principal_credentials WHERE credential_id = ? AND version = ?").bind(credentialId, version).first<{ principal: string }>() |
| 76 | + if (!row) return false |
| 77 | + const [result] = await this.db.batch([ |
| 78 | + this.db.prepare("UPDATE wp_codebox_principal_credentials SET revoked_at = ? WHERE credential_id = ? AND version = ? AND revoked_at IS NULL").bind(now, credentialId, version), |
| 79 | + this.db.prepare("INSERT INTO wp_codebox_principal_credential_audit (at, kind, principal, credential_id, version, reason, dedupe_key) SELECT ?, 'revoked', ?, ?, ?, 'ok', NULL WHERE changes() = 1").bind(now, row.principal, credentialId, version), |
| 80 | + this.trimAuditStatement(), |
| 81 | + ]) |
| 82 | + return result.meta.changes === 1 |
| 83 | + } |
| 84 | + |
| 85 | + async authenticate(bearer: string, now = Date.now()): Promise<AuthenticatedPrincipal | null> { |
| 86 | + const decision = await this.authorizeDigest(await bearerDigest(bearer), undefined, undefined, now) |
| 87 | + if (decision.status === "not-found") await this.recordUnknownDenial(now) |
| 88 | + return decision.status === "allowed" ? decision.principal : null |
| 89 | + } |
| 90 | + |
| 91 | + async authorize(bearer: string, scope: string, siteId?: string, now = Date.now()): Promise<AuthenticatedPrincipal | null> { |
| 92 | + const decision = await this.authorizeDigest(await bearerDigest(bearer), scope, siteId, now) |
| 93 | + if (decision.status === "not-found") await this.recordUnknownDenial(now) |
| 94 | + return decision.status === "allowed" ? decision.principal : null |
| 95 | + } |
| 96 | + |
| 97 | + async authorizeDigest(digest: string, scope?: string, siteId?: string, now = Date.now()): Promise<CredentialAuthorizationDecision> { |
| 98 | + if (!/^[a-f0-9]{64}$/.test(digest) || (scope !== undefined && !SCOPES.has(scope)) || (siteId !== undefined && !validSiteId(siteId)) || !Number.isSafeInteger(now) || now < 1) throw new Error("Credential authorization request is invalid.") |
| 99 | + await this.initialize() |
| 100 | + const row = await this.db.prepare("SELECT credential_id, version, principal, scopes, expires_at, max_sites, sites, revoked_at FROM wp_codebox_principal_credentials WHERE digest = ?").bind(digest).first<Record<string, unknown>>() |
| 101 | + if (!row) return { status: "not-found" } |
| 102 | + const value = hydrate(row) |
| 103 | + const reason = row.revoked_at !== null ? "revoked" : Date.parse(value.expiresAt) <= now ? "expired" : scope && !value.scopes.includes(scope) ? "scope" : siteId && value.sites && !value.sites.includes(siteId) ? "site" : null |
| 104 | + if (reason) { |
| 105 | + await this.audit("denied", value.principal, value.credentialId, value.version, reason, now) |
| 106 | + return { status: "denied", reason, principal: value.principal, credentialId: value.credentialId, version: value.version } |
| 107 | + } |
| 108 | + await this.audit("authenticated", value.principal, value.credentialId, value.version, "ok", now) |
| 109 | + return { status: "allowed", principal: value } |
| 110 | + } |
| 111 | + |
| 112 | + async recordUnknownDenial(now = Date.now()): Promise<void> { |
| 113 | + if (!Number.isSafeInteger(now) || now < 1) throw new Error("Credential audit time is invalid.") |
| 114 | + await this.initialize() |
| 115 | + const bucket = Math.floor(now / UNKNOWN_AUDIT_BUCKET_MS) |
| 116 | + await this.db.batch([ |
| 117 | + this.db.prepare("INSERT OR IGNORE INTO wp_codebox_principal_credential_audit (at, kind, principal, credential_id, version, reason, dedupe_key) VALUES (?, 'denied', NULL, NULL, NULL, 'unknown', ?)").bind(now, `unknown:${bucket}`), |
| 118 | + this.trimAuditStatement(), |
| 119 | + ]) |
| 120 | + } |
| 121 | + |
| 122 | + async auditEvents(limit = 100): Promise<CredentialAuditEvent[]> { |
| 123 | + await this.initialize() |
| 124 | + const bounded = Number.isSafeInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 100 |
| 125 | + const rows = await this.db.prepare("SELECT id, at, kind, principal, credential_id, version, reason FROM wp_codebox_principal_credential_audit ORDER BY id DESC LIMIT ?").bind(bounded).all<CredentialAuditEvent & { credential_id: string | null }>() |
| 126 | + return rows.results.map(({ credential_id: credentialId, ...event }) => Object.freeze({ ...event, credentialId })) |
| 127 | + } |
| 128 | + |
| 129 | + private async audit(kind: CredentialAuditEvent["kind"], principal: string | null, credentialId: string | null, version: string | null, reason: CredentialAuditEvent["reason"], at = Date.now()): Promise<void> { |
| 130 | + await this.db.batch([this.auditStatement(kind, principal, credentialId, version, reason, at), this.trimAuditStatement()]) |
| 131 | + } |
| 132 | + |
| 133 | + private auditStatement(kind: CredentialAuditEvent["kind"], principal: string | null, credentialId: string | null, version: string | null, reason: CredentialAuditEvent["reason"], at: number): D1PreparedStatement { |
| 134 | + const dedupeKey = kind === "denied" ? `denied:${credentialId}:${version}:${reason}:${Math.floor(at / UNKNOWN_AUDIT_BUCKET_MS)}` : null |
| 135 | + return this.db.prepare("INSERT OR IGNORE INTO wp_codebox_principal_credential_audit (at, kind, principal, credential_id, version, reason, dedupe_key) VALUES (?, ?, ?, ?, ?, ?, ?)").bind(at, kind, principal, credentialId, version, reason, dedupeKey) |
| 136 | + } |
| 137 | + private trimAuditStatement(): D1PreparedStatement { return this.db.prepare("DELETE FROM wp_codebox_principal_credential_audit WHERE id IN (SELECT id FROM wp_codebox_principal_credential_audit ORDER BY id DESC LIMIT -1 OFFSET ?)").bind(this.auditLimit) } |
| 138 | +} |
| 139 | + |
| 140 | +function validate(input: PrincipalCredentialVersion): void { |
| 141 | + if (!identifier(input.credentialId) || !identifier(input.version) || !principalId(input.principal) || !/^[a-f0-9]{64}$/.test(input.digest) || !validPolicy(input)) throw new Error("Credential policy is invalid.") |
| 142 | +} |
| 143 | +function validPolicy(value: PrincipalCredentialPolicy): boolean { |
| 144 | + let canonicalExpiry: string |
| 145 | + try { canonicalExpiry = new Date(value.expiresAt).toISOString() } catch { return false } |
| 146 | + return Array.isArray(value.scopes) && value.scopes.length > 0 && value.scopes.length <= 4 && new Set(value.scopes).size === value.scopes.length && value.scopes.every((scope) => SCOPES.has(scope)) && Number.isInteger(value.maxSites) && value.maxSites >= 0 && value.maxSites <= 10_000 && canonicalExpiry === value.expiresAt && Date.parse(value.expiresAt) > 0 && (value.sites === undefined || (Array.isArray(value.sites) && value.sites.length <= 256 && new Set(value.sites).size === value.sites.length && value.sites.every(validSiteId))) |
| 147 | +} |
| 148 | +function hydrate(row: Record<string, unknown>): AuthenticatedPrincipal { |
| 149 | + const scopes = parseArray(row.scopes) |
| 150 | + const sites = row.sites === null ? undefined : parseArray(row.sites) |
| 151 | + let expiresAt: string |
| 152 | + try { expiresAt = new Date(Number(row.expires_at)).toISOString() } catch { throw new Error("Stored credential policy is invalid.") } |
| 153 | + if (!identifier(row.credential_id) || !identifier(row.version) || !principalId(row.principal) || !scopes || (row.sites !== null && !sites)) throw new Error("Stored credential policy is invalid.") |
| 154 | + const value: AuthenticatedPrincipal = { credentialId: row.credential_id, version: row.version, principal: row.principal, scopes, expiresAt, maxSites: Number(row.max_sites), ...(sites ? { sites } : {}) } |
| 155 | + if (!validPolicy(value)) throw new Error("Stored credential policy is invalid.") |
| 156 | + return value |
| 157 | +} |
| 158 | +function sameCredential(row: Record<string, unknown>, input: PrincipalCredentialVersion, scopes: string, sites: string | null, expiresAt: number): boolean { return row.digest === input.digest && row.principal === input.principal && row.scopes === scopes && row.expires_at === expiresAt && row.max_sites === input.maxSites && row.sites === sites } |
| 159 | +function parseArray(value: unknown): string[] | null { try { const parsed = JSON.parse(value as string); return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : null } catch { return null } } |
| 160 | +function identifier(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(value) } |
| 161 | +function principalId(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value) } |
| 162 | +function validSiteId(value: unknown): value is string { return typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 63 } |
| 163 | +async function bearerDigest(value: string): Promise<string> { const bytes = new TextEncoder().encode(value); if (!value || bytes.byteLength > 4_096) throw new Error("Credential bearer is invalid."); return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>)), (byte) => byte.toString(16).padStart(2, "0")).join("") } |
0 commit comments