| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; |
| |
| |
| |
| const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; |
| const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/; |
|
|
| export class BriefUrlError extends Error { |
| readonly code: 'invalid_user_id' | 'invalid_issue_date' | 'missing_secret'; |
|
|
| constructor(code: BriefUrlError['code'], message: string) { |
| super(message); |
| this.code = code; |
| this.name = 'BriefUrlError'; |
| } |
| } |
|
|
| function assertShape(userId: string, issueDate: string): void { |
| if (!USER_ID_RE.test(userId)) { |
| throw new BriefUrlError('invalid_user_id', 'userId must match [A-Za-z0-9_-]{1,128}'); |
| } |
| if (!ISSUE_DATE_RE.test(issueDate)) { |
| throw new BriefUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD-HHMM'); |
| } |
| } |
|
|
| function base64url(bytes: Uint8Array): string { |
| let bin = ''; |
| for (const b of bytes) bin += String.fromCharCode(b); |
| return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| } |
|
|
| async function hmacSha256(secret: string, message: string): Promise<Uint8Array> { |
| const key = await crypto.subtle.importKey( |
| 'raw', |
| new TextEncoder().encode(secret), |
| { name: 'HMAC', hash: 'SHA-256' }, |
| false, |
| ['sign'], |
| ); |
| const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); |
| return new Uint8Array(sig); |
| } |
|
|
| |
| function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { |
| if (a.length !== b.length) return false; |
| let diff = 0; |
| for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0); |
| return diff === 0; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function signBriefToken( |
| userId: string, |
| issueDate: string, |
| secret: string, |
| ): Promise<string> { |
| assertShape(userId, issueDate); |
| if (!secret) { |
| throw new BriefUrlError('missing_secret', 'BRIEF_URL_SIGNING_SECRET is not configured'); |
| } |
| const sig = await hmacSha256(secret, `${userId}:${issueDate}`); |
| return base64url(sig); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function verifyBriefToken( |
| userId: string, |
| issueDate: string, |
| token: string, |
| secret: string, |
| prevSecret?: string, |
| ): Promise<boolean> { |
| if (typeof token !== 'string' || !TOKEN_RE.test(token)) return false; |
| try { |
| assertShape(userId, issueDate); |
| } catch { |
| return false; |
| } |
| if (!secret) { |
| throw new BriefUrlError('missing_secret', 'BRIEF_URL_SIGNING_SECRET is not configured'); |
| } |
|
|
| const tokenBytes = base64urlDecode(token); |
| if (!tokenBytes) return false; |
|
|
| const message = `${userId}:${issueDate}`; |
| const primary = await hmacSha256(secret, message); |
| if (constantTimeEqual(primary, tokenBytes)) return true; |
|
|
| if (prevSecret) { |
| const legacy = await hmacSha256(prevSecret, message); |
| if (constantTimeEqual(legacy, tokenBytes)) return true; |
| } |
| return false; |
| } |
|
|
| function base64urlDecode(token: string): Uint8Array | null { |
| try { |
| const b64 = token.replace(/-/g, '+').replace(/_/g, '/'); |
| const padded = b64 + '==='.slice((b64.length + 3) % 4); |
| const bin = atob(padded); |
| const out = new Uint8Array(bin.length); |
| for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| return out; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function signBriefUrl({ |
| userId, |
| issueDate, |
| baseUrl, |
| secret, |
| }: { |
| userId: string; |
| /** Legacy name for the frozen issueSlot (`YYYY-MM-DD-HHMM`). */ |
| issueDate: string; |
| baseUrl: string; |
| secret: string; |
| }): Promise<string> { |
| const token = await signBriefToken(userId, issueDate, secret); |
| const encodedUser = encodeURIComponent(userId); |
| const encodedDate = encodeURIComponent(issueDate); |
| const trimmedBase = baseUrl.replace(/\/+$/, ''); |
| return `${trimmedBase}/api/brief/${encodedUser}/${encodedDate}?t=${token}`; |
| } |
|
|