@willyim/idp 0.1.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/LICENSE +21 -0
- package/README.md +278 -0
- package/dist/src/api.d.ts +77 -0
- package/dist/src/api.d.ts.map +1 -0
- package/dist/src/api.js +50 -0
- package/dist/src/claims.d.ts +46 -0
- package/dist/src/claims.d.ts.map +1 -0
- package/dist/src/claims.js +61 -0
- package/dist/src/client.d.ts +112 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +155 -0
- package/dist/src/cookie.d.ts +19 -0
- package/dist/src/cookie.d.ts.map +1 -0
- package/dist/src/cookie.js +49 -0
- package/dist/src/crypto.d.ts +32 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +76 -0
- package/dist/src/drizzle/index.d.ts +873 -0
- package/dist/src/drizzle/index.d.ts.map +1 -0
- package/dist/src/drizzle/index.js +130 -0
- package/dist/src/duration.d.ts +8 -0
- package/dist/src/duration.d.ts.map +1 -0
- package/dist/src/duration.js +24 -0
- package/dist/src/generated/idp-api.d.ts +1022 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +20 -0
- package/dist/src/react-router/index.d.ts +75 -0
- package/dist/src/react-router/index.d.ts.map +1 -0
- package/dist/src/react-router/index.js +110 -0
- package/dist/src/session.d.ts +141 -0
- package/dist/src/session.d.ts.map +1 -0
- package/dist/src/session.js +369 -0
- package/dist/src/store.d.ts +44 -0
- package/dist/src/store.d.ts.map +1 -0
- package/dist/src/store.js +41 -0
- package/openapi/idp-api.json +1344 -0
- package/package.json +78 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1 — the app session. The cookie holds an HMAC-signed opaque session id;
|
|
3
|
+
* everything else lives in the row. The signature is checked before the store is
|
|
4
|
+
* touched, so a junk cookie never costs a query.
|
|
5
|
+
*
|
|
6
|
+
* Permissions are a TTL-cached projection of IdP truth: inside the freshness
|
|
7
|
+
* window we read the row, past it we re-read `/userinfo`. That per-freshness
|
|
8
|
+
* call doubles as a liveness ping — it is how a revocation at the IdP reaches
|
|
9
|
+
* the app, in minutes rather than in session-lengths.
|
|
10
|
+
*/
|
|
11
|
+
import { grants } from "./claims.js";
|
|
12
|
+
import { createIdpClient, createPkce, IdpError, } from "./client.js";
|
|
13
|
+
import { clearCookie, readCookie, serializeCookie } from "./cookie.js";
|
|
14
|
+
import { base64urlDecodeString, base64urlEncodeString, createSigner, randomToken, } from "./crypto.js";
|
|
15
|
+
import { parseDuration } from "./duration.js";
|
|
16
|
+
export const DEFAULT_SESSION_COOKIE = "idp_session";
|
|
17
|
+
/** How long the login handshake may take before its state cookie is stale. */
|
|
18
|
+
const STATE_COOKIE_MAX_AGE_MS = 10 * 60_000;
|
|
19
|
+
export function createIdp(options) {
|
|
20
|
+
const client = createIdpClient(options);
|
|
21
|
+
const store = options.sessions;
|
|
22
|
+
const signer = createSigner(options.session.secret);
|
|
23
|
+
const now = options.now ?? (() => new Date());
|
|
24
|
+
const cookieName = options.session.cookieName ?? DEFAULT_SESSION_COOKIE;
|
|
25
|
+
const stateCookieName = `${cookieName}_oauth`;
|
|
26
|
+
const cookieOptions = {
|
|
27
|
+
path: options.session.path ?? "/",
|
|
28
|
+
domain: options.session.cookieDomain,
|
|
29
|
+
httpOnly: true,
|
|
30
|
+
secure: options.session.secure !== false,
|
|
31
|
+
sameSite: options.session.sameSite ?? "lax",
|
|
32
|
+
};
|
|
33
|
+
const configuredExpiresIn = parseDuration(options.session.expiresIn ?? "7d");
|
|
34
|
+
const updateAge = parseDuration(options.session.updateAge ?? "1d");
|
|
35
|
+
const freshness = parseDuration(options.session.freshness ?? "5m");
|
|
36
|
+
const absoluteExpiresIn = options.session.absoluteExpiresIn
|
|
37
|
+
? parseDuration(options.session.absoluteExpiresIn)
|
|
38
|
+
: null;
|
|
39
|
+
let expiresInPromise = null;
|
|
40
|
+
/**
|
|
41
|
+
* The session length after the IdP's ceiling is applied. An app that declares
|
|
42
|
+
* a longer session than its IdP registration permits gets the IdP's number,
|
|
43
|
+
* with a warning in development. When the IdP advertises no ceiling — it does
|
|
44
|
+
* not yet — the configured value stands.
|
|
45
|
+
*/
|
|
46
|
+
function resolveExpiresIn() {
|
|
47
|
+
expiresInPromise ??= client
|
|
48
|
+
.discover()
|
|
49
|
+
.then((discovery) => {
|
|
50
|
+
const ceiling = discovery.session_max_age;
|
|
51
|
+
if (typeof ceiling !== "number" || ceiling <= 0)
|
|
52
|
+
return configuredExpiresIn;
|
|
53
|
+
const ceilingMs = ceiling * 1000;
|
|
54
|
+
if (configuredExpiresIn <= ceilingMs)
|
|
55
|
+
return configuredExpiresIn;
|
|
56
|
+
warn(`session.expiresIn (${configuredExpiresIn}ms) exceeds the ceiling this app is ` +
|
|
57
|
+
`registered for at ${options.issuer} (${ceilingMs}ms) and was clamped. ` +
|
|
58
|
+
`Raise the per-app session ceiling in the IdP console to lift it.`);
|
|
59
|
+
return ceilingMs;
|
|
60
|
+
})
|
|
61
|
+
.catch(() => configuredExpiresIn);
|
|
62
|
+
return expiresInPromise;
|
|
63
|
+
}
|
|
64
|
+
function warn(message) {
|
|
65
|
+
// Read `process` off globalThis rather than as a bare identifier: core is
|
|
66
|
+
// built without node types and must load on runtimes that have no `process`.
|
|
67
|
+
const env = globalThis.process
|
|
68
|
+
?.env;
|
|
69
|
+
const production = env?.NODE_ENV === "production";
|
|
70
|
+
if (options.debug === false || (options.debug === undefined && production))
|
|
71
|
+
return;
|
|
72
|
+
console.warn(`[@willyim/idp] ${message}`);
|
|
73
|
+
}
|
|
74
|
+
function sessionCookie(id, maxAgeMs) {
|
|
75
|
+
return serializeCookie(cookieName, id, { ...cookieOptions, maxAge: maxAgeMs / 1000 });
|
|
76
|
+
}
|
|
77
|
+
/** Signed cookie -> session id, or null. Nothing here touches the store. */
|
|
78
|
+
async function readSessionId(request) {
|
|
79
|
+
const signed = readCookie(request, cookieName);
|
|
80
|
+
if (!signed)
|
|
81
|
+
return null;
|
|
82
|
+
return signer.unsign(signed);
|
|
83
|
+
}
|
|
84
|
+
function toSession(row, renewed) {
|
|
85
|
+
return {
|
|
86
|
+
id: row.id,
|
|
87
|
+
sub: row.sub,
|
|
88
|
+
email: row.email,
|
|
89
|
+
name: row.name,
|
|
90
|
+
image: row.image,
|
|
91
|
+
permissions: row.permissions,
|
|
92
|
+
workspaces: row.workspaces,
|
|
93
|
+
actor: row.actor,
|
|
94
|
+
createdAt: row.createdAt,
|
|
95
|
+
expiresAt: row.expiresAt,
|
|
96
|
+
can: (permission) => grants(row.permissions, permission),
|
|
97
|
+
renewCookie: () => renewed,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function claimPatch(claims) {
|
|
101
|
+
return {
|
|
102
|
+
email: claims.email || undefined,
|
|
103
|
+
name: claims.name,
|
|
104
|
+
image: claims.image,
|
|
105
|
+
permissions: claims.permissions,
|
|
106
|
+
workspaces: claims.workspaces,
|
|
107
|
+
actor: claims.actor,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function tokenPatch(tokens) {
|
|
111
|
+
return {
|
|
112
|
+
accessToken: tokens.accessToken,
|
|
113
|
+
// A rotating IdP returns a new refresh token; a non-rotating one returns
|
|
114
|
+
// none, and the old one stays valid.
|
|
115
|
+
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
|
|
116
|
+
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
|
|
117
|
+
accessTokenExpiresAt: expiryOf(tokens.expiresIn),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function expiryOf(expiresIn) {
|
|
121
|
+
return expiresIn === null ? null : new Date(now().getTime() + expiresIn * 1000);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Re-read claims from the IdP. A 401 means the access token died early, so we
|
|
125
|
+
* refresh and retry exactly once. A failed refresh means the grant is gone —
|
|
126
|
+
* revoked, expired, or the user was deleted — and the row goes with it.
|
|
127
|
+
*/
|
|
128
|
+
async function sync(row) {
|
|
129
|
+
let patch = {};
|
|
130
|
+
let accessToken = row.accessToken;
|
|
131
|
+
let refreshToken = row.refreshToken;
|
|
132
|
+
let refreshed = false;
|
|
133
|
+
const doRefresh = async () => {
|
|
134
|
+
if (!refreshToken)
|
|
135
|
+
return false;
|
|
136
|
+
try {
|
|
137
|
+
const tokens = await client.refresh(refreshToken);
|
|
138
|
+
patch = { ...patch, ...tokenPatch(tokens) };
|
|
139
|
+
accessToken = tokens.accessToken;
|
|
140
|
+
refreshToken = tokens.refreshToken ?? refreshToken;
|
|
141
|
+
refreshed = true;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const expired = row.accessTokenExpiresAt !== null && row.accessTokenExpiresAt <= now();
|
|
149
|
+
if (expired && !(await doRefresh())) {
|
|
150
|
+
await store.delete(row.id);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
let claims;
|
|
154
|
+
try {
|
|
155
|
+
claims = await client.userinfo(accessToken);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (!isUnauthorized(error)) {
|
|
159
|
+
// The IdP is unreachable or broken. Serving slightly stale claims beats
|
|
160
|
+
// logging the world out over someone else's outage; `syncedAt` is left
|
|
161
|
+
// alone so the next request tries again.
|
|
162
|
+
return refreshed ? ((await store.update(row.id, patch)) ?? row) : row;
|
|
163
|
+
}
|
|
164
|
+
if (refreshed || !(await doRefresh())) {
|
|
165
|
+
await store.delete(row.id);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
claims = await client.userinfo(accessToken);
|
|
170
|
+
}
|
|
171
|
+
catch (retryError) {
|
|
172
|
+
if (!isUnauthorized(retryError))
|
|
173
|
+
return (await store.update(row.id, patch)) ?? row;
|
|
174
|
+
await store.delete(row.id);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return store.update(row.id, { ...patch, ...claimPatch(claims), syncedAt: now() });
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Slide the idle timeout, but only once the session is `updateAge` into its
|
|
182
|
+
* life — otherwise every request would write. `absoluteExpiresIn` caps it.
|
|
183
|
+
*/
|
|
184
|
+
async function slide(row) {
|
|
185
|
+
const expiresIn = await resolveExpiresIn();
|
|
186
|
+
const current = now();
|
|
187
|
+
if (row.expiresAt.getTime() - current.getTime() > expiresIn - updateAge) {
|
|
188
|
+
return { row, cookie: null };
|
|
189
|
+
}
|
|
190
|
+
let expiresAt = new Date(current.getTime() + expiresIn);
|
|
191
|
+
if (absoluteExpiresIn) {
|
|
192
|
+
const cap = new Date(row.createdAt.getTime() + absoluteExpiresIn);
|
|
193
|
+
if (cap < expiresAt)
|
|
194
|
+
expiresAt = cap;
|
|
195
|
+
}
|
|
196
|
+
if (expiresAt <= row.expiresAt)
|
|
197
|
+
return { row, cookie: null };
|
|
198
|
+
const updated = (await store.update(row.id, { expiresAt })) ?? { ...row, expiresAt };
|
|
199
|
+
return {
|
|
200
|
+
row: updated,
|
|
201
|
+
cookie: sessionCookie(await signedId(row.id), expiresAt.getTime() - current.getTime()),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function signedId(id) {
|
|
205
|
+
return signer.pack(id);
|
|
206
|
+
}
|
|
207
|
+
async function destroySession(request) {
|
|
208
|
+
const id = await readSessionId(request);
|
|
209
|
+
if (id)
|
|
210
|
+
await store.delete(id);
|
|
211
|
+
const headers = new Headers();
|
|
212
|
+
headers.append("set-cookie", clearCookie(cookieName, cookieOptions));
|
|
213
|
+
headers.append("set-cookie", clearCookie(stateCookieName, cookieOptions));
|
|
214
|
+
return headers;
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
/** The Layer 0 client, for anything the session layer doesn't wrap. */
|
|
218
|
+
client,
|
|
219
|
+
/**
|
|
220
|
+
* Step one of login: where to send the browser, plus the `Set-Cookie` that
|
|
221
|
+
* carries the CSRF state and the PKCE verifier through the round trip.
|
|
222
|
+
*/
|
|
223
|
+
async startLogin(input) {
|
|
224
|
+
const { codeVerifier, codeChallenge } = await createPkce();
|
|
225
|
+
const state = randomToken(16);
|
|
226
|
+
const url = await client.authorizationUrl({
|
|
227
|
+
redirectUri: input.redirectUri,
|
|
228
|
+
state,
|
|
229
|
+
codeChallenge,
|
|
230
|
+
prompt: input.prompt,
|
|
231
|
+
loginHint: input.loginHint,
|
|
232
|
+
});
|
|
233
|
+
const payload = { state, codeVerifier, next: safeNext(input.next) };
|
|
234
|
+
const headers = new Headers();
|
|
235
|
+
headers.append("set-cookie", serializeCookie(stateCookieName, await signer.pack(base64urlEncodeString(JSON.stringify(payload))), { ...cookieOptions, maxAge: STATE_COOKIE_MAX_AGE_MS / 1000 }));
|
|
236
|
+
return { url, headers };
|
|
237
|
+
},
|
|
238
|
+
/**
|
|
239
|
+
* Step two: verify the state, exchange the code, read live claims, and write
|
|
240
|
+
* the session row. Returns the `Set-Cookie` headers and where to go next.
|
|
241
|
+
*/
|
|
242
|
+
async completeLogin(request, input) {
|
|
243
|
+
const url = new URL(request.url);
|
|
244
|
+
const error = url.searchParams.get("error");
|
|
245
|
+
if (error) {
|
|
246
|
+
throw new IdpError(`authorization failed: ${url.searchParams.get("error_description") ?? error}`, 400, error);
|
|
247
|
+
}
|
|
248
|
+
const code = url.searchParams.get("code");
|
|
249
|
+
const state = url.searchParams.get("state");
|
|
250
|
+
if (!code || !state)
|
|
251
|
+
throw new IdpError("callback is missing code or state", 400);
|
|
252
|
+
const signed = readCookie(request, stateCookieName);
|
|
253
|
+
const raw = signed ? await signer.unsign(signed) : null;
|
|
254
|
+
if (!raw)
|
|
255
|
+
throw new IdpError("login state cookie is missing or invalid", 400);
|
|
256
|
+
let payload;
|
|
257
|
+
try {
|
|
258
|
+
payload = JSON.parse(base64urlDecodeString(raw));
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
throw new IdpError("login state cookie is malformed", 400);
|
|
262
|
+
}
|
|
263
|
+
if (payload.state !== state)
|
|
264
|
+
throw new IdpError("state mismatch", 400);
|
|
265
|
+
const tokens = await client.exchangeCode({
|
|
266
|
+
code,
|
|
267
|
+
redirectUri: input.redirectUri,
|
|
268
|
+
codeVerifier: payload.codeVerifier,
|
|
269
|
+
});
|
|
270
|
+
const claims = await client.userinfo(tokens.accessToken);
|
|
271
|
+
const created = now();
|
|
272
|
+
const expiresIn = await resolveExpiresIn();
|
|
273
|
+
let expiresAt = new Date(created.getTime() + expiresIn);
|
|
274
|
+
if (absoluteExpiresIn) {
|
|
275
|
+
const cap = new Date(created.getTime() + absoluteExpiresIn);
|
|
276
|
+
if (cap < expiresAt)
|
|
277
|
+
expiresAt = cap;
|
|
278
|
+
}
|
|
279
|
+
const id = randomToken(32);
|
|
280
|
+
const row = await store.create({
|
|
281
|
+
id,
|
|
282
|
+
sub: claims.sub,
|
|
283
|
+
email: claims.email,
|
|
284
|
+
name: claims.name,
|
|
285
|
+
image: claims.image,
|
|
286
|
+
permissions: claims.permissions,
|
|
287
|
+
workspaces: claims.workspaces,
|
|
288
|
+
actor: claims.actor,
|
|
289
|
+
accessToken: tokens.accessToken,
|
|
290
|
+
refreshToken: tokens.refreshToken,
|
|
291
|
+
idToken: tokens.idToken,
|
|
292
|
+
accessTokenExpiresAt: expiryOf(tokens.expiresIn),
|
|
293
|
+
syncedAt: created,
|
|
294
|
+
expiresAt,
|
|
295
|
+
createdAt: created,
|
|
296
|
+
});
|
|
297
|
+
const headers = new Headers();
|
|
298
|
+
headers.append("set-cookie", sessionCookie(await signedId(id), expiresAt.getTime() - created.getTime()));
|
|
299
|
+
headers.append("set-cookie", clearCookie(stateCookieName, cookieOptions));
|
|
300
|
+
return { session: toSession(row, null), headers, next: payload.next ?? "/" };
|
|
301
|
+
},
|
|
302
|
+
/**
|
|
303
|
+
* The session for this request, or null. One store read on the common path;
|
|
304
|
+
* a `/userinfo` round trip once the freshness window has closed.
|
|
305
|
+
*/
|
|
306
|
+
async getSession(request) {
|
|
307
|
+
const id = await readSessionId(request);
|
|
308
|
+
if (!id)
|
|
309
|
+
return null;
|
|
310
|
+
let row = await store.get(id);
|
|
311
|
+
if (!row)
|
|
312
|
+
return null;
|
|
313
|
+
if (row.expiresAt <= now()) {
|
|
314
|
+
await store.delete(row.id);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const stale = now().getTime() - row.syncedAt.getTime() >= freshness;
|
|
318
|
+
const tokenExpired = row.accessTokenExpiresAt !== null && row.accessTokenExpiresAt <= now();
|
|
319
|
+
if (stale || tokenExpired) {
|
|
320
|
+
const synced = await sync(row);
|
|
321
|
+
if (!synced)
|
|
322
|
+
return null;
|
|
323
|
+
row = synced;
|
|
324
|
+
}
|
|
325
|
+
const slid = await slide(row);
|
|
326
|
+
return toSession(slid.row, slid.cookie);
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* Log this browser out: delete the row, expire the cookie. The returned
|
|
330
|
+
* headers also clear any half-finished login state.
|
|
331
|
+
*/
|
|
332
|
+
destroySession,
|
|
333
|
+
/** Log a subject out of every browser. Instant — revocation is a row delete. */
|
|
334
|
+
async destroyAllSessions(sub) {
|
|
335
|
+
await store.deleteBySub(sub);
|
|
336
|
+
},
|
|
337
|
+
/**
|
|
338
|
+
* Log out locally *and* at the IdP. The row is deleted and the cookie
|
|
339
|
+
* expired before anything else happens, so however the RP-initiated logout
|
|
340
|
+
* goes — endpoint missing, client not registered for it, IdP down — the
|
|
341
|
+
* visitor is never left signed in here.
|
|
342
|
+
*
|
|
343
|
+
* `url` is the IdP's end-session URL to send the browser to, or null when
|
|
344
|
+
* there isn't a usable one; redirect somewhere local in that case.
|
|
345
|
+
*/
|
|
346
|
+
async logout(request, input = {}) {
|
|
347
|
+
const id = await readSessionId(request);
|
|
348
|
+
const row = id ? await store.get(id) : null;
|
|
349
|
+
const headers = await destroySession(request);
|
|
350
|
+
if (input.idpLogout === false)
|
|
351
|
+
return { headers, url: null };
|
|
352
|
+
const url = await client
|
|
353
|
+
.logoutUrl({ idToken: row?.idToken, redirectTo: input.redirectTo })
|
|
354
|
+
.catch(() => null);
|
|
355
|
+
return { headers, url };
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function isUnauthorized(error) {
|
|
360
|
+
return error instanceof IdpError && (error.status === 401 || error.status === 403);
|
|
361
|
+
}
|
|
362
|
+
/** Only same-site paths survive as `?next=` — an open redirect is not a feature. */
|
|
363
|
+
export function safeNext(next) {
|
|
364
|
+
if (!next)
|
|
365
|
+
return undefined;
|
|
366
|
+
if (!next.startsWith("/") || next.startsWith("//"))
|
|
367
|
+
return undefined;
|
|
368
|
+
return next;
|
|
369
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one table a consumer app owns. It is a *handle* to IdP truth, not a copy
|
|
3
|
+
* of it: the claim columns are a TTL-cached projection refreshed from
|
|
4
|
+
* `/userinfo`, and deleting a row logs that browser out on its next request.
|
|
5
|
+
*
|
|
6
|
+
* The store is an interface so the package isn't married to drizzle —
|
|
7
|
+
* `memorySessions()` is the whole contract in thirty lines, and the tests run
|
|
8
|
+
* against it.
|
|
9
|
+
*/
|
|
10
|
+
import type { Actor, Workspace } from "./claims.js";
|
|
11
|
+
export type SessionRecord = {
|
|
12
|
+
/** Opaque, random. The cookie carries this id plus an HMAC over it. */
|
|
13
|
+
id: string;
|
|
14
|
+
sub: string;
|
|
15
|
+
email: string;
|
|
16
|
+
name: string | null;
|
|
17
|
+
image: string | null;
|
|
18
|
+
permissions: string[];
|
|
19
|
+
workspaces: Workspace[];
|
|
20
|
+
actor: Actor | null;
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken: string | null;
|
|
23
|
+
idToken: string | null;
|
|
24
|
+
accessTokenExpiresAt: Date | null;
|
|
25
|
+
/** Last successful `/userinfo` read — the freshness window is measured from here. */
|
|
26
|
+
syncedAt: Date;
|
|
27
|
+
expiresAt: Date;
|
|
28
|
+
createdAt: Date;
|
|
29
|
+
};
|
|
30
|
+
export type SessionStore = {
|
|
31
|
+
get(id: string): Promise<SessionRecord | null>;
|
|
32
|
+
create(record: SessionRecord): Promise<SessionRecord>;
|
|
33
|
+
update(id: string, patch: Partial<SessionRecord>): Promise<SessionRecord | null>;
|
|
34
|
+
delete(id: string): Promise<void>;
|
|
35
|
+
/** Log out everywhere: every session this subject holds in this app. */
|
|
36
|
+
deleteBySub(sub: string): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
export type MemorySessionStore = SessionStore & {
|
|
39
|
+
/** Every live record. Tests assert on this; nothing else should. */
|
|
40
|
+
all(): SessionRecord[];
|
|
41
|
+
};
|
|
42
|
+
/** In-memory store for tests and single-process development. Nothing survives a restart. */
|
|
43
|
+
export declare function memorySessions(): MemorySessionStore;
|
|
44
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEnD,MAAM,MAAM,aAAa,GAAG;IAC1B,uEAAuE;IACvE,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,UAAU,EAAE,SAAS,EAAE,CAAA;IACvB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,oBAAoB,EAAE,IAAI,GAAG,IAAI,CAAA;IACjC,qFAAqF;IACrF,QAAQ,EAAE,IAAI,CAAA;IACd,SAAS,EAAE,IAAI,CAAA;IACf,SAAS,EAAE,IAAI,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAC9C,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;IACrD,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,aAAa,CAAC,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAChF,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjC,wEAAwE;IACxE,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACxC,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG,YAAY,GAAG;IAC9C,oEAAoE;IACpE,GAAG,IAAI,aAAa,EAAE,CAAA;CACvB,CAAA;AAED,4FAA4F;AAC5F,wBAAgB,cAAc,IAAI,kBAAkB,CA2BnD"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one table a consumer app owns. It is a *handle* to IdP truth, not a copy
|
|
3
|
+
* of it: the claim columns are a TTL-cached projection refreshed from
|
|
4
|
+
* `/userinfo`, and deleting a row logs that browser out on its next request.
|
|
5
|
+
*
|
|
6
|
+
* The store is an interface so the package isn't married to drizzle —
|
|
7
|
+
* `memorySessions()` is the whole contract in thirty lines, and the tests run
|
|
8
|
+
* against it.
|
|
9
|
+
*/
|
|
10
|
+
/** In-memory store for tests and single-process development. Nothing survives a restart. */
|
|
11
|
+
export function memorySessions() {
|
|
12
|
+
const rows = new Map();
|
|
13
|
+
return {
|
|
14
|
+
async get(id) {
|
|
15
|
+
return rows.get(id) ?? null;
|
|
16
|
+
},
|
|
17
|
+
async create(record) {
|
|
18
|
+
rows.set(record.id, { ...record });
|
|
19
|
+
return { ...record };
|
|
20
|
+
},
|
|
21
|
+
async update(id, patch) {
|
|
22
|
+
const row = rows.get(id);
|
|
23
|
+
if (!row)
|
|
24
|
+
return null;
|
|
25
|
+
const next = { ...row, ...patch, id };
|
|
26
|
+
rows.set(id, next);
|
|
27
|
+
return { ...next };
|
|
28
|
+
},
|
|
29
|
+
async delete(id) {
|
|
30
|
+
rows.delete(id);
|
|
31
|
+
},
|
|
32
|
+
async deleteBySub(sub) {
|
|
33
|
+
for (const [id, row] of rows)
|
|
34
|
+
if (row.sub === sub)
|
|
35
|
+
rows.delete(id);
|
|
36
|
+
},
|
|
37
|
+
all() {
|
|
38
|
+
return [...rows.values()];
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|