@steve31415/baselib 3.44.0 → 3.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +82 -9
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context } from 'hono';
|
|
2
|
-
import type
|
|
2
|
+
import { type Logger } from './log-core.js';
|
|
3
3
|
import { ServiceCaller, S2sOptions } from './s2s.js';
|
|
4
4
|
export declare const DEFAULT_AUTH_URL = "https://auth.apps.snewman.net";
|
|
5
5
|
export declare const SESSION_COOKIE = "pw_session";
|
package/dist/auth.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
// policy, app-level checks) still runs.
|
|
10
10
|
// 2. Authorization: Bearer → service-to-service OIDC (s2s.ts).
|
|
11
11
|
// 3. pw_session cookie → whoami call to the auth service, cached
|
|
12
|
-
// in-instance (10 min hits / 30 s misses — the old-world numbers)
|
|
12
|
+
// in-instance (10 min hits / 30 s misses — the old-world numbers),
|
|
13
|
+
// one call per cookie at a time (concurrent misses share it).
|
|
14
|
+
// Every call (never a cache hit) logs one INFO 'session check' event
|
|
15
|
+
// saying why the cache could not answer and what the call cost the
|
|
16
|
+
// waiting request (~/migration/research/whoami-latency-2026-10-30.md).
|
|
13
17
|
//
|
|
14
18
|
// Then the user-access policy: by default an app admits only users whose
|
|
15
19
|
// auth role is 'owner'; `userAccess: 'invited'` admits any active fleet
|
|
@@ -25,6 +29,7 @@
|
|
|
25
29
|
import { createHash } from 'node:crypto';
|
|
26
30
|
import { createMiddleware } from 'hono/factory';
|
|
27
31
|
import { authMode } from './config.js';
|
|
32
|
+
import { truncate } from './log-core.js';
|
|
28
33
|
import { RateLimiter, resolveS2sCaller } from './s2s.js';
|
|
29
34
|
export const DEFAULT_AUTH_URL = 'https://auth.apps.snewman.net';
|
|
30
35
|
export const SESSION_COOKIE = 'pw_session';
|
|
@@ -46,6 +51,13 @@ export function requireIdentity(opts) {
|
|
|
46
51
|
const policy = opts.userAccess ?? 'owner';
|
|
47
52
|
const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
|
|
48
53
|
const cache = new Map();
|
|
54
|
+
// The whoami call in flight per cache key (single-flight).
|
|
55
|
+
const inFlight = new Map();
|
|
56
|
+
// Session-check log state (per middleware, like the cache): cache hits
|
|
57
|
+
// since the last whoami call, and when the last call ended.
|
|
58
|
+
let hitsSinceLastCall = 0;
|
|
59
|
+
let callCount = 0;
|
|
60
|
+
let lastCallEndedAt;
|
|
49
61
|
const isApi = opts.isApiRequest ??
|
|
50
62
|
((c) => c.req.path.startsWith('/api/') || (c.req.header('accept') ?? '').includes('application/json'));
|
|
51
63
|
const unauthenticated = (c) => {
|
|
@@ -108,18 +120,77 @@ export function requireIdentity(opts) {
|
|
|
108
120
|
const key = createHash('sha256').update(cookieHeader).digest('hex');
|
|
109
121
|
const cached = cache.get(key);
|
|
110
122
|
if (cached && cached.expires > Date.now()) {
|
|
123
|
+
hitsSinceLastCall++;
|
|
111
124
|
if (!cached.user)
|
|
112
125
|
return unauthenticated(c);
|
|
113
126
|
return admit(cached.user);
|
|
114
127
|
}
|
|
128
|
+
// Single-flight: a request that misses while a call for the same cookie
|
|
129
|
+
// is already in flight shares that call's answer instead of making its
|
|
130
|
+
// own (30% of calls were such duplicates). Every request still gets an
|
|
131
|
+
// answer fetched during its own lifetime, so what is admitted does not
|
|
132
|
+
// change; the answer is applied per request below.
|
|
133
|
+
const flight = inFlight.get(key);
|
|
134
|
+
if (flight) {
|
|
135
|
+
flight.joiners.count++;
|
|
136
|
+
return settle(await flight.outcome);
|
|
137
|
+
}
|
|
138
|
+
const joiners = { count: 0 };
|
|
139
|
+
const outcome = callWhoami(c, key, cached, cookieHeader, joiners).finally(() => inFlight.delete(key));
|
|
140
|
+
inFlight.set(key, { outcome, joiners });
|
|
141
|
+
return settle(await outcome);
|
|
142
|
+
function settle(outcome) {
|
|
143
|
+
if (outcome.kind === 'user')
|
|
144
|
+
return admit(outcome.user);
|
|
145
|
+
if (outcome.kind === 'unauthenticated')
|
|
146
|
+
return unauthenticated(c);
|
|
147
|
+
return c.json({ error: 'auth service unavailable' }, 503);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
// One whoami call, whose answer every request that joined it shares. The
|
|
151
|
+
// waiting request pays its full latency. One INFO event per call (a few
|
|
152
|
+
// hundred a day fleet-wide; never per hit or per joined request), with a
|
|
153
|
+
// fixed vocabulary of keys and nothing from the cookie but how many
|
|
154
|
+
// cookies the header carries (a changing header defeats the cache).
|
|
155
|
+
async function callWhoami(c, key, cached, cookieHeader, joiners) {
|
|
156
|
+
const started = Date.now();
|
|
157
|
+
const cacheState = !cached ? 'absent' : cached.user ? 'expired-user' : 'expired-unauthenticated';
|
|
158
|
+
const callFields = {
|
|
159
|
+
cacheState,
|
|
160
|
+
...(cached ? { entryAgeMs: started - cached.filled } : {}),
|
|
161
|
+
hitsSinceLastCall,
|
|
162
|
+
...(lastCallEndedAt !== undefined ? { sinceLastCallMs: started - lastCallEndedAt } : {}),
|
|
163
|
+
...(callCount === 0 ? { firstCall: true } : {}),
|
|
164
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
165
|
+
method: c.req.method,
|
|
166
|
+
path: truncate(c.req.path, 200),
|
|
167
|
+
upgrade: (c.req.header('upgrade') ?? '').toLowerCase() === 'websocket',
|
|
168
|
+
cookieCount: cookieHeader.split(';').filter((part) => part.includes('=')).length,
|
|
169
|
+
};
|
|
170
|
+
hitsSinceLastCall = 0;
|
|
171
|
+
callCount++;
|
|
172
|
+
// `joinedRequests`: requests that shared this call (single-flight),
|
|
173
|
+
// counted when the call ends, so it includes every joiner.
|
|
174
|
+
const callEnded = (status) => {
|
|
175
|
+
const ended = Date.now();
|
|
176
|
+
lastCallEndedAt = ended;
|
|
177
|
+
opts.logger?.info('session check', {
|
|
178
|
+
...callFields,
|
|
179
|
+
joinedRequests: joiners.count,
|
|
180
|
+
status,
|
|
181
|
+
elapsedMs: ended - started,
|
|
182
|
+
});
|
|
183
|
+
};
|
|
115
184
|
let res;
|
|
116
185
|
try {
|
|
117
186
|
res = await whoamiFetch(`${authUrl}/api/whoami`, { headers: { cookie: cookieHeader } });
|
|
118
187
|
}
|
|
119
188
|
catch (err) {
|
|
189
|
+
callEnded(0);
|
|
120
190
|
opts.logger?.error('auth service unreachable', { error: err });
|
|
121
|
-
return
|
|
191
|
+
return { kind: 'unavailable' };
|
|
122
192
|
}
|
|
193
|
+
callEnded(res.status);
|
|
123
194
|
if (res.ok) {
|
|
124
195
|
const data = (await res.json());
|
|
125
196
|
const role = parseRole(data.role);
|
|
@@ -128,19 +199,21 @@ export function requireIdentity(opts) {
|
|
|
128
199
|
// protocol change: a fault, not an "invited" answer. Fail closed,
|
|
129
200
|
// loudly, and without caching so recovery is instant.
|
|
130
201
|
opts.logger?.error('whoami answer has no role', { email: data.email, role: data.role });
|
|
131
|
-
return
|
|
202
|
+
return { kind: 'unavailable' };
|
|
132
203
|
}
|
|
133
204
|
const user = { type: 'user', email: data.email, name: data.name, userId: data.user_id, role };
|
|
134
|
-
|
|
135
|
-
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
cache.set(key, { expires: now + hitMs, filled: now, user });
|
|
207
|
+
return { kind: 'user', user };
|
|
136
208
|
}
|
|
137
209
|
if (res.status === 401) {
|
|
138
|
-
|
|
139
|
-
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
cache.set(key, { expires: now + missMs, filled: now });
|
|
212
|
+
return { kind: 'unauthenticated' };
|
|
140
213
|
}
|
|
141
214
|
opts.logger?.error('auth service error', { meta_status: res.status });
|
|
142
|
-
return
|
|
143
|
-
}
|
|
215
|
+
return { kind: 'unavailable' };
|
|
216
|
+
}
|
|
144
217
|
}
|
|
145
218
|
// The fleet-wide "not enabled" page, in the auth service's plain style so
|
|
146
219
|
// every app shows the same thing. Carries nothing but the request's host,
|
package/package.json
CHANGED