Repository navigation
Expand file tree
/
Copy pathauth.js
More file actions
133 lines (111 loc) · 5.79 KB
/
Copy pathauth.js
File metadata and controls
133 lines (111 loc) · 5.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
// The password gate: checking the password, the signed session cookie, and the rate limits.
//
// There is no user database. Knowing ACCESS_PASSWORD earns a cookie that says "signed in until
// <time>", with an HMAC so it cannot be forged or extended. Everything the server needs to
// check that cookie comes from the environment, so it survives restarts and works the same on
// every replica.
const crypto = require('node:crypto');
const SESSION_DAYS = 30;
const sha256 = (value) => crypto.createHash('sha256').update(String(value)).digest();
function createAuth({ password, apiKey }) {
// The signing key is derived from the password AND the API key. Stable, because both come
// from the environment: no restart or second replica logs anyone out. Changing the password
// changes the key, which signs everyone out at once. And the API key's entropy means a
// leaked cookie cannot be used to brute-force a weak password offline.
const key = Buffer.from(crypto.hkdfSync('sha256', `${password}\n${apiKey}`, 'velixir-ai-chat', 'session-cookie-v1', 32));
const passwordDigest = sha256(password);
const sign = (payload) => crypto.createHmac('sha256', key).update(payload).digest('base64url');
return {
// Both sides are hashed first so the comparison is always 32 bytes against 32 bytes:
// timingSafeEqual then leaks neither the password nor its length through timing.
checkPassword(input) {
return crypto.timingSafeEqual(sha256(String(input ?? '').trim()), passwordDigest);
},
issue() {
const expires = Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400;
const payload = `v1.${expires}`;
return `${payload}.${sign(payload)}`;
},
verify(token) {
if (typeof token !== 'string') return false;
const [version, expires, mac] = token.split('.');
if (version !== 'v1' || !/^\d+$/.test(expires ?? '') || !mac) return false;
if (Number(expires) * 1000 <= Date.now()) return false;
const expected = Buffer.from(sign(`${version}.${expires}`));
const actual = Buffer.from(mac);
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
},
};
}
// ─── The cookie ─────────────────────────────────────────────────────────────
// The edge terminates TLS, so the browser always talks HTTPS to this app and the cookie can
// be Secure. The __Host- prefix makes the browser refuse the cookie unless it is Secure and
// set for exactly this host, so another app under the same parent domain cannot plant or
// overwrite it. The one exception is http://localhost during development, where some
// browsers drop Secure cookies.
const isLocalhost = (req) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(req.headers.host || '');
const cookieName = (req) => (isLocalhost(req) ? 'ai-chat-session' : '__Host-ai-chat-session');
function readSession(req) {
const name = cookieName(req);
for (const part of (req.headers.cookie || '').split(';')) {
const eq = part.indexOf('=');
if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
}
return null;
}
function sessionCookie(req, value, maxAgeSeconds = SESSION_DAYS * 86400) {
const secure = isLocalhost(req) ? '' : '; Secure';
return `${cookieName(req)}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`;
}
const clearedCookie = (req) => sessionCookie(req, '', 0);
// ─── Rate limits ────────────────────────────────────────────────────────────
// Which address a request came from. velixir's edge passes the visitor's address in
// X-Forwarded-For and X-Real-IP, overwriting whatever the browser sent. Behind some proxies
// every visitor shows up as the same address, so treat these limits as a brake on runaway
// spending, not as proof of who is who: the password is what keeps strangers out.
function clientIp(req) {
const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
const ip = forwarded || String(req.headers['x-real-ip'] || '').trim() || req.socket.remoteAddress || 'unknown';
return ip.replace(/^::ffff:/, '');
}
// A fixed window per key, in memory. Memory is the right place for a single small app: the
// counts reset on restart, which only ever errs on the side of letting the owner back in.
// With several replicas each keeps its own counts.
class RateLimiter {
constructor({ limit, windowMs }) {
this.limit = limit;
this.windowMs = windowMs;
this.windows = new Map();
setInterval(() => this.sweep(), windowMs).unref();
}
current(key) {
const now = Date.now();
let window = this.windows.get(key);
if (!window || window.resetAt <= now) {
// A flood of distinct keys cannot grow this without bound.
if (this.windows.size >= 10_000) this.windows.clear();
window = { count: 0, resetAt: now + this.windowMs };
this.windows.set(key, window);
}
return window;
}
// Seconds until the key may try again, or 0 if it is under the limit.
blockedFor(key) {
const window = this.current(key);
return window.count >= this.limit ? Math.max(Math.ceil((window.resetAt - Date.now()) / 1000), 1) : 0;
}
hit(key) {
this.current(key).count += 1;
}
// Check and count in one go. Returns 0 when allowed, otherwise seconds to wait.
take(key) {
const wait = this.blockedFor(key);
if (!wait) this.hit(key);
return wait;
}
sweep() {
const now = Date.now();
for (const [key, window] of this.windows) if (window.resetAt <= now) this.windows.delete(key);
}
}
module.exports = { createAuth, readSession, sessionCookie, clearedCookie, clientIp, RateLimiter };