auth-hardening.ts75 lines · main
1/**
2 * Sprint S4 — pure security helpers (unit-testable without DNS/Redis/DB).
3 *
4 * Domain TXT match, open-redirect RelayState checks, SDK key method scopes.
5 */
6
7/** Preferred DNS TXT payload for org domain ownership. */
8export function domainVerificationTxt(token: string): string {
9 return `briven-domain-verification=${token}`;
10}
11
12/**
13 * True if any DNS TXT record proves ownership of `token`.
14 * Accepts either the raw token or the preferred `briven-domain-verification=` form.
15 */
16export function txtRecordsContainDomainToken(
17 txtRecords: string[][],
18 token: string,
19): boolean {
20 if (!token) return false;
21 const preferred = domainVerificationTxt(token);
22 return txtRecords.some((chunks) => {
23 const joined = chunks.join('');
24 return joined.includes(preferred) || joined.includes(token);
25 });
26}
27
28/**
29 * Validate a post-SSO redirect target against an allowlist of origins.
30 * Relative paths `/...` are allowed. Absolute URLs must match an origin.
31 * Protocol-relative `//evil.com` and non-http(s) schemes are rejected.
32 */
33export function sanitizeRelayState(
34 relayState: string | null | undefined,
35 allowedOrigins: readonly string[],
36): string {
37 if (!relayState || relayState === '/') return '/';
38 // Relative path only (not protocol-relative).
39 if (relayState.startsWith('/') && !relayState.startsWith('//')) {
40 return relayState;
41 }
42 try {
43 const url = new URL(relayState);
44 if (url.protocol !== 'http:' && url.protocol !== 'https:') {
45 return '/';
46 }
47 const origin = `${url.protocol}//${url.host}`;
48 const allowed = allowedOrigins
49 .filter(Boolean)
50 .map((a) => a.toLowerCase());
51 if (allowed.includes(origin.toLowerCase())) {
52 return relayState;
53 }
54 } catch {
55 // invalid URL
56 }
57 return '/';
58}
59
60export type AuthSdkKeyScope = 'read' | 'read-write' | 'admin';
61
62/**
63 * Whether an SDK key scope may call this HTTP method on the auth-tenant bridge.
64 * `read` = safe methods only. `read-write` and `admin` = all methods.
65 */
66export function sdkKeyAllowsMethod(scope: AuthSdkKeyScope | string, method: string): boolean {
67 const m = method.toUpperCase();
68 if (scope === 'read') {
69 return m === 'GET' || m === 'HEAD' || m === 'OPTIONS';
70 }
71 if (scope === 'read-write' || scope === 'admin') {
72 return true;
73 }
74 return false;
75}