@wtfalch/auth 0.5.0 → 0.7.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.
@@ -0,0 +1,153 @@
1
+ /** Versioned server-owned configuration. URL/email input is a lookup key, never
2
+ * authority. Workspace membership and session/transaction binding remain the
3
+ * responsibility of the consumer; this module does not establish a session. */
4
+ export type NamespaceStatus = 'pending' | 'active' | 'suspended' | 'failed';
5
+ export interface NamespaceDomain {
6
+ domain: string;
7
+ purpose: 'account' | 'login';
8
+ status: 'pending' | 'active';
9
+ /** Operator-owned evidence reference; this is not an automated DNS verifier. */
10
+ verificationRef?: string;
11
+ }
12
+ /** A browser service: the SDK's server or browser client at one origin. `kind` is omitted. */
13
+ export interface NamespaceService {
14
+ kind?: 'web';
15
+ id: string;
16
+ serviceId: string;
17
+ deploymentId: string;
18
+ appOrigin: string;
19
+ redirectUris: string[];
20
+ postLogoutRedirectUris: string[];
21
+ credentialRef: string;
22
+ registration: boolean;
23
+ }
24
+ /**
25
+ * Installed software a person signs in to by approving on another device: a
26
+ * public device-code client with no secret and no redirect URIs.
27
+ */
28
+ export interface NamespaceNativeService {
29
+ kind: 'native';
30
+ id: string;
31
+ serviceId: string;
32
+ deploymentId: string;
33
+ }
34
+ /**
35
+ * A resource server that checks the namespace's tokens at the introspection
36
+ * endpoint. Its client secret is stored under `credentialRef`, never here.
37
+ */
38
+ export interface NamespaceApiService {
39
+ kind: 'api';
40
+ id: string;
41
+ serviceId: string;
42
+ deploymentId: string;
43
+ credentialRef: string;
44
+ }
45
+ /** Every client of one namespace shares its accounts: one sign-in across all of them. */
46
+ export type NamespaceBinding = NamespaceService | NamespaceNativeService | NamespaceApiService;
47
+ export declare function isWebService<T extends NamespaceBinding>(binding: T): binding is Extract<T, NamespaceService>;
48
+ export interface NamespaceSpec {
49
+ id: string;
50
+ displayName: string;
51
+ revision: number;
52
+ status: Exclude<NamespaceStatus, 'failed'>;
53
+ /**
54
+ * `invitation`: nobody signs themselves up. People arrive by invitation,
55
+ * whether from the organization's admin or when a mailbox is created on
56
+ * one of its `account` domains. Omitted means open, which is how every
57
+ * namespace behaved before.
58
+ */
59
+ admission?: 'invitation';
60
+ loginOrigin: string;
61
+ domains: NamespaceDomain[];
62
+ mail: {
63
+ from: string;
64
+ productName: string;
65
+ locale: string;
66
+ };
67
+ services: NamespaceBinding[];
68
+ }
69
+ export interface NamespaceManifest {
70
+ version: 1;
71
+ issuer: string;
72
+ namespaces: NamespaceSpec[];
73
+ }
74
+ export interface NamespaceRecord extends Omit<NamespaceSpec, 'status' | 'services'> {
75
+ status: NamespaceStatus;
76
+ organizationId: string;
77
+ projectId: string;
78
+ services: (NamespaceBinding & {
79
+ appId: string;
80
+ clientId: string;
81
+ })[];
82
+ }
83
+ export interface NamespaceRegistry {
84
+ version: 1;
85
+ issuer: string;
86
+ namespaces: NamespaceRecord[];
87
+ }
88
+ export interface WorkspaceBinding {
89
+ serviceId: string;
90
+ workspaceId: string;
91
+ namespaceId: string;
92
+ }
93
+ export declare function parseNamespaceManifest(raw: unknown): NamespaceManifest;
94
+ export declare function parseNamespaceRegistry(raw: unknown): NamespaceRegistry;
95
+ /** Exact server-side configuration lookup. It grants no workspace membership. */
96
+ export declare function resolveNamespaceBinding(registry: NamespaceRegistry, input: {
97
+ namespaceId: string;
98
+ serviceId: string;
99
+ deploymentId: string;
100
+ appOrigin: string;
101
+ }): {
102
+ kind?: "web";
103
+ id: string;
104
+ serviceId: string;
105
+ deploymentId: string;
106
+ appOrigin: string;
107
+ redirectUris: string[];
108
+ postLogoutRedirectUris: string[];
109
+ credentialRef: string;
110
+ registration: boolean;
111
+ appId: string;
112
+ clientId: string;
113
+ issuer: string;
114
+ namespaceId: string;
115
+ organizationId: string;
116
+ revision: number;
117
+ loginOrigin: string;
118
+ };
119
+ /** Apps store these mappings themselves; email addresses are deliberately absent. */
120
+ export declare function parseWorkspaceBindings(raw: unknown, registry: NamespaceRegistry): WorkspaceBinding[];
121
+ /** Trusted server configuration, or its public projection for a browser client. */
122
+ export interface NamespaceSelection {
123
+ registry: NamespaceRegistry;
124
+ namespaceId: string;
125
+ serviceId: string;
126
+ deploymentId: string;
127
+ }
128
+ /** A deterministic binding used inside protected cookies and browser transactions. */
129
+ export declare function resolveNamespaceContext(selection: NamespaceSelection, options: {
130
+ issuer: string;
131
+ clientId: string;
132
+ appOrigin: string;
133
+ redirectUri: string;
134
+ organizationId?: string;
135
+ }): {
136
+ context: string;
137
+ kind?: "web";
138
+ id: string;
139
+ serviceId: string;
140
+ deploymentId: string;
141
+ appOrigin: string;
142
+ redirectUris: string[];
143
+ postLogoutRedirectUris: string[];
144
+ credentialRef: string;
145
+ registration: boolean;
146
+ appId: string;
147
+ clientId: string;
148
+ issuer: string;
149
+ namespaceId: string;
150
+ organizationId: string;
151
+ revision: number;
152
+ loginOrigin: string;
153
+ };
@@ -0,0 +1,280 @@
1
+ export function isWebService(binding) {
2
+ return binding.kind === undefined || binding.kind === 'web';
3
+ }
4
+ function invalid(field) {
5
+ throw new Error(`Invalid namespace configuration: ${field}`);
6
+ }
7
+ function object(value, field) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value))
9
+ invalid(field);
10
+ return value;
11
+ }
12
+ function string(value, field) {
13
+ if (typeof value !== 'string' ||
14
+ !value ||
15
+ value.trim() !== value ||
16
+ value.length > 250 ||
17
+ [...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
18
+ invalid(field);
19
+ return value;
20
+ }
21
+ function id(value, field) {
22
+ const result = string(value, field);
23
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(result) ||
24
+ ['constructor', 'prototype', '__proto__'].includes(result))
25
+ invalid(field);
26
+ return result;
27
+ }
28
+ function array(value, field) {
29
+ if (!Array.isArray(value) || value.length > 1000)
30
+ invalid(field);
31
+ return value;
32
+ }
33
+ function oneOf(value, choices, field) {
34
+ if (!choices.includes(value))
35
+ invalid(field);
36
+ return value;
37
+ }
38
+ function url(value, field, originOnly, allowLocal = false) {
39
+ const raw = string(value, field);
40
+ let result;
41
+ try {
42
+ result = new URL(raw);
43
+ }
44
+ catch {
45
+ return invalid(field);
46
+ }
47
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(result.hostname) ||
48
+ result.hostname.endsWith('.localhost');
49
+ if ((result.protocol !== 'https:' && !(allowLocal && local && result.protocol === 'http:')) ||
50
+ result.username ||
51
+ result.password ||
52
+ result.hash ||
53
+ result.hostname.endsWith('.'))
54
+ invalid(field);
55
+ if (originOnly && (result.pathname !== '/' || result.search))
56
+ invalid(field);
57
+ return originOnly ? result.origin : result.href;
58
+ }
59
+ function unique(values, field) {
60
+ if (new Set(values).size !== values.length)
61
+ invalid(`duplicate ${field}`);
62
+ }
63
+ function uris(value, origin, field) {
64
+ const result = array(value, field).map((v) => url(v, field, false, true));
65
+ if (!result.length || result.some((v) => new URL(v).origin !== origin || v.includes('*')))
66
+ invalid(field);
67
+ unique(result, field);
68
+ return result;
69
+ }
70
+ function domain(value) {
71
+ const d = object(value, 'domain');
72
+ const name = string(d.domain, 'domain').toLowerCase();
73
+ if (name.length > 253 ||
74
+ !name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
75
+ invalid('domain');
76
+ const purpose = oneOf(d.purpose, ['account', 'login'], 'domain purpose');
77
+ const status = oneOf(d.status, ['pending', 'active'], 'domain status');
78
+ const verificationRef = d.verificationRef === undefined ? undefined : string(d.verificationRef, 'verificationRef');
79
+ if (status === 'active' && !verificationRef)
80
+ invalid('active domain needs verificationRef');
81
+ return { domain: name, purpose, status, ...(verificationRef ? { verificationRef } : {}) };
82
+ }
83
+ function credential(value) {
84
+ const credentialRef = string(value, 'credentialRef');
85
+ if (!/^[A-Z][A-Z0-9_]{2,100}$/.test(credentialRef) || credentialRef.endsWith('_PAT'))
86
+ invalid('credentialRef');
87
+ return credentialRef;
88
+ }
89
+ const WEB_FIELDS = ['appOrigin', 'redirectUris', 'postLogoutRedirectUris', 'registration'];
90
+ function service(value) {
91
+ const s = object(value, 'service');
92
+ const kind = s.kind === undefined ? 'web' : oneOf(s.kind, ['web', 'native', 'api'], 'kind');
93
+ const identity = {
94
+ id: id(s.id, 'binding id'),
95
+ serviceId: id(s.serviceId, 'serviceId'),
96
+ deploymentId: id(s.deploymentId, 'deploymentId'),
97
+ };
98
+ if (kind !== 'web') {
99
+ // Nothing is redirected to installed software or a resource server; a
100
+ // stray browser field is a mistaken kind, not something to ignore.
101
+ if (WEB_FIELDS.some((field) => s[field] !== undefined))
102
+ invalid(`${kind} service web fields`);
103
+ if (kind === 'native') {
104
+ if (s.credentialRef !== undefined)
105
+ invalid('native service credentialRef');
106
+ return { kind, ...identity };
107
+ }
108
+ return { kind, ...identity, credentialRef: credential(s.credentialRef) };
109
+ }
110
+ const appOrigin = url(s.appOrigin, 'appOrigin', true, true);
111
+ const credentialRef = credential(s.credentialRef);
112
+ if (s.registration !== undefined && typeof s.registration !== 'boolean')
113
+ invalid('registration');
114
+ return {
115
+ ...identity,
116
+ appOrigin,
117
+ redirectUris: uris(s.redirectUris, appOrigin, 'redirectUris'),
118
+ postLogoutRedirectUris: uris(s.postLogoutRedirectUris, appOrigin, 'postLogoutRedirectUris'),
119
+ credentialRef,
120
+ registration: s.registration === true,
121
+ };
122
+ }
123
+ function parse(raw, resolved) {
124
+ const input = object(raw, 'document');
125
+ if (input.version !== 1)
126
+ invalid('version');
127
+ const issuer = url(input.issuer, 'issuer', true);
128
+ const namespaces = array(input.namespaces, 'namespaces').map((value) => {
129
+ const n = object(value, 'namespace');
130
+ const mail = object(n.mail, 'mail');
131
+ if (!Number.isSafeInteger(n.revision) || n.revision < 1)
132
+ invalid('revision');
133
+ const loginOrigin = url(n.loginOrigin, 'loginOrigin', true);
134
+ const domains = array(n.domains, 'domains').map(domain);
135
+ unique(domains.map((d) => `${d.purpose}:${d.domain}`), 'domain purpose');
136
+ const status = oneOf(n.status, resolved
137
+ ? ['pending', 'active', 'suspended', 'failed']
138
+ : ['pending', 'active', 'suspended'], 'status');
139
+ const loginDomain = domains.find((d) => d.purpose === 'login' && d.domain === new URL(loginOrigin).hostname);
140
+ if (loginOrigin !== issuer && !loginDomain)
141
+ invalid('custom login origin needs a domain binding');
142
+ if (status === 'active' &&
143
+ (domains.some((d) => d.status !== 'active') ||
144
+ (loginOrigin !== issuer && loginDomain?.status !== 'active')))
145
+ invalid('active namespace has unverified domains');
146
+ const services = array(n.services, 'services').map((value) => {
147
+ const spec = service(value);
148
+ if (!resolved)
149
+ return spec;
150
+ const r = object(value, 'resolved service');
151
+ return { ...spec, appId: string(r.appId, 'appId'), clientId: string(r.clientId, 'clientId') };
152
+ });
153
+ if (!services.length)
154
+ invalid('namespace needs a service');
155
+ unique(services.map((s) => `${s.serviceId}:${s.deploymentId}`), 'namespace service/deployment');
156
+ const admission = oneOf(n.admission ?? 'open', ['open', 'invitation'], 'admission');
157
+ if (admission === 'invitation') {
158
+ // The organization's addresses are what an invitation-only namespace
159
+ // is for: mail provisioning invites from them.
160
+ if (!domains.some((d) => d.purpose === 'account'))
161
+ invalid('invitation namespace needs an account domain');
162
+ if (services.some((s) => isWebService(s) && s.registration))
163
+ invalid('invitation namespace cannot open registration');
164
+ }
165
+ const common = {
166
+ id: id(n.id, 'namespace id'),
167
+ displayName: string(n.displayName, 'displayName'),
168
+ revision: n.revision,
169
+ status,
170
+ ...(admission === 'invitation' ? { admission } : {}),
171
+ loginOrigin,
172
+ domains,
173
+ services,
174
+ mail: {
175
+ from: string(mail.from, 'mail.from'),
176
+ productName: string(mail.productName, 'mail.productName'),
177
+ locale: string(mail.locale, 'mail.locale'),
178
+ },
179
+ };
180
+ return resolved
181
+ ? {
182
+ ...common,
183
+ organizationId: string(n.organizationId, 'organizationId'),
184
+ projectId: string(n.projectId, 'projectId'),
185
+ }
186
+ : common;
187
+ });
188
+ unique(namespaces.map((n) => n.id), 'namespace id');
189
+ unique(namespaces.flatMap((n) => n.services.map((s) => s.id)), 'binding id');
190
+ unique(namespaces.flatMap((n) => n.services.flatMap((s) => ('credentialRef' in s ? [s.credentialRef] : []))), 'credential reference');
191
+ const domains = new Map();
192
+ for (const n of namespaces)
193
+ for (const d of n.domains) {
194
+ const owner = domains.get(d.domain);
195
+ // Pending and suspended claims also reserve ownership until an explicit
196
+ // migration releases them. A registry edit cannot silently reassign a domain.
197
+ if (owner && owner !== n.id)
198
+ invalid('domain belongs to multiple namespaces');
199
+ domains.set(d.domain, n.id);
200
+ }
201
+ if (resolved) {
202
+ const records = namespaces;
203
+ unique(records.map((n) => n.organizationId), 'organizationId');
204
+ unique(records.flatMap((n) => n.services.map((s) => s.clientId)), 'clientId');
205
+ }
206
+ return { version: 1, issuer, namespaces };
207
+ }
208
+ export function parseNamespaceManifest(raw) {
209
+ return parse(raw, false);
210
+ }
211
+ export function parseNamespaceRegistry(raw) {
212
+ return parse(raw, true);
213
+ }
214
+ /** Exact server-side configuration lookup. It grants no workspace membership. */
215
+ export function resolveNamespaceBinding(registry, input) {
216
+ const validated = parseNamespaceRegistry(registry);
217
+ const namespace = validated.namespaces.find((n) => n.id === input.namespaceId);
218
+ if (!namespace || namespace.status !== 'active')
219
+ invalid('namespace unavailable');
220
+ const binding = namespace.services
221
+ .filter(isWebService)
222
+ .find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
223
+ if (!binding || binding.appOrigin !== url(input.appOrigin, 'appOrigin', true, true))
224
+ invalid('service binding mismatch');
225
+ return {
226
+ issuer: validated.issuer,
227
+ namespaceId: namespace.id,
228
+ organizationId: namespace.organizationId,
229
+ revision: namespace.revision,
230
+ loginOrigin: namespace.loginOrigin,
231
+ ...binding,
232
+ };
233
+ }
234
+ /** Apps store these mappings themselves; email addresses are deliberately absent. */
235
+ export function parseWorkspaceBindings(raw, registry) {
236
+ const validated = parseNamespaceRegistry(registry);
237
+ const result = array(raw, 'workspace bindings').map((value) => {
238
+ const w = object(value, 'workspace binding');
239
+ const binding = {
240
+ serviceId: id(w.serviceId, 'serviceId'),
241
+ workspaceId: id(w.workspaceId, 'workspaceId'),
242
+ namespaceId: id(w.namespaceId, 'namespaceId'),
243
+ };
244
+ const namespace = validated.namespaces.find((n) => n.id === binding.namespaceId);
245
+ if (!namespace?.services.some((s) => isWebService(s) && s.serviceId === binding.serviceId))
246
+ invalid('workspace service/namespace mismatch');
247
+ return binding;
248
+ });
249
+ unique(result.map((w) => `${w.serviceId}:${w.workspaceId}`), 'workspace binding');
250
+ return result;
251
+ }
252
+ /** A deterministic binding used inside protected cookies and browser transactions. */
253
+ export function resolveNamespaceContext(selection, options) {
254
+ const binding = resolveNamespaceBinding(selection.registry, {
255
+ ...selection,
256
+ appOrigin: options.appOrigin,
257
+ });
258
+ if (binding.issuer !== options.issuer ||
259
+ binding.clientId !== options.clientId ||
260
+ (options.organizationId !== undefined && binding.organizationId !== options.organizationId) ||
261
+ !binding.redirectUris.includes(options.redirectUri))
262
+ invalid('authentication options disagree with binding');
263
+ return {
264
+ ...binding,
265
+ context: JSON.stringify([
266
+ 1,
267
+ binding.namespaceId,
268
+ binding.id,
269
+ binding.serviceId,
270
+ binding.deploymentId,
271
+ binding.revision,
272
+ binding.issuer,
273
+ binding.organizationId,
274
+ binding.clientId,
275
+ binding.appOrigin,
276
+ options.redirectUri,
277
+ binding.loginOrigin,
278
+ ]),
279
+ };
280
+ }
package/dist/next.js CHANGED
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server';
4
4
  import { createAuth, } from './auth.js';
5
5
  import { BrokerError } from './broker.js';
6
6
  import { withCookies } from './cookies.js';
7
+ import { AuthError } from './oidc.js';
7
8
  /**
8
9
  * Which of the two failures it was, in the vocabulary `auth_error` already
9
10
  * uses. A refusal under 500 is about this request — the broker says unknown,
@@ -24,7 +25,13 @@ export function nextAuth(options) {
24
25
  response = NextResponse.redirect(gate.location, 303);
25
26
  }
26
27
  else if (gate.kind === 'deny') {
27
- response = new NextResponse('Unauthorized', { status: 401 });
28
+ response = new NextResponse(gate.reason === 'account_changed'
29
+ ? 'Account changed; choose an account to continue'
30
+ : gate.reason === 'unavailable'
31
+ ? 'Sign-in is unavailable for this workspace'
32
+ : 'Unauthorized', {
33
+ status: gate.reason === 'account_changed' ? 409 : gate.reason === 'unavailable' ? 503 : 401,
34
+ });
28
35
  }
29
36
  else if (gate.cookies.length > 0) {
30
37
  const headers = new Headers(request.headers);
@@ -46,7 +53,12 @@ export function nextAuth(options) {
46
53
  return user;
47
54
  };
48
55
  const requireUser = async (next) => {
49
- const user = await getUser();
56
+ const store = await cookies();
57
+ const { user, accountChanged, unavailable } = await auth.readCookie(store.get(auth.sessionCookieName)?.value, { refresh: false });
58
+ if (accountChanged)
59
+ throw new AuthError('account_changed', 'Choose an account to continue');
60
+ if (unavailable)
61
+ throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
50
62
  if (!user)
51
63
  redirect(auth.startUrl(next));
52
64
  return user;
package/dist/oidc.d.ts CHANGED
@@ -28,7 +28,7 @@ export declare class Oidc {
28
28
  verify(idToken: string): Promise<jose.JWTPayload>;
29
29
  private assertOrganization;
30
30
  }
31
- export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request';
31
+ export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable';
32
32
  export declare class AuthError extends Error {
33
33
  readonly reason: AuthErrorReason;
34
34
  constructor(reason: AuthErrorReason, message: string);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Server-side records behind namespace sessions.
3
+ *
4
+ * The browser holds a sealed handle that never changes after sign-in; the
5
+ * tokens live here. That is what makes three things possible that a sealed
6
+ * token cookie cannot do: a logout that also kills copies of the cookie, a
7
+ * refresh that no late response can undo by setting an older cookie, and two
8
+ * replicas refreshing the same session without logging the person out.
9
+ *
10
+ * The store is the app's. It sees a digest of the handle, never the handle,
11
+ * and tokens sealed with the app's cookie secret, never the tokens.
12
+ */
13
+ export interface SessionRecord {
14
+ /** SHA-256 of the handle, base64url. */
15
+ key: string;
16
+ /** Digest of the namespace binding and revision the session was made under. */
17
+ context: string;
18
+ /** The issuer's user id. */
19
+ subject: string;
20
+ /** Bumped by every write. `advance` only succeeds from the current value. */
21
+ generation: number;
22
+ /**
23
+ * Unix seconds until which one request holds the right to refresh, or null.
24
+ * The issuer rotates refresh tokens, so two replicas refreshing at once
25
+ * would spend the same token and one would be refused. The lease is how
26
+ * exactly one of them asks.
27
+ */
28
+ leaseUntil: number | null;
29
+ /** The tokens, sealed by the SDK. Opaque here. */
30
+ payload: string;
31
+ /** Unix seconds. Slides forward on every refresh. */
32
+ expiresAt: number;
33
+ /** Unix seconds, or null while the session is live. */
34
+ revokedAt: number | null;
35
+ }
36
+ export type SessionUpdate = Pick<SessionRecord, 'payload' | 'expiresAt' | 'leaseUntil'>;
37
+ export interface SessionStore {
38
+ create(record: SessionRecord): Promise<void>;
39
+ get(key: string): Promise<SessionRecord | null>;
40
+ /**
41
+ * Replace the payload, expiry and lease, and bump the generation, only if the
42
+ * record is still at `generation` and not revoked. Resolves whether it did.
43
+ */
44
+ advance(key: string, generation: number, next: SessionUpdate): Promise<boolean>;
45
+ revoke(key: string, at: number): Promise<void>;
46
+ /** Revoke every live record for this subject under this binding context. Resolves how many. */
47
+ revokeSubject(context: string, subject: string, at: number): Promise<number>;
48
+ }
49
+ /**
50
+ * One process only. For tests and a single-instance development server: two
51
+ * replicas with separate memory stores do not share revocations.
52
+ */
53
+ export declare function createMemorySessionStore(): SessionStore;
54
+ /** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
55
+ export declare const POSTGRES_SESSION_TABLE = "create table if not exists wtfalch_auth_sessions (\n key text primary key,\n context text not null,\n subject text not null,\n generation integer not null,\n payload text not null,\n expires_at bigint not null,\n lease_until bigint,\n revoked_at bigint\n);\ncreate index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);\ncreate index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);";
56
+ type Row = Record<string, unknown>;
57
+ /**
58
+ * Postgres through whatever driver the app already has. `query` runs one
59
+ * parameterised statement and resolves its rows:
60
+ *
61
+ * - node-postgres: `(text, values) => pool.query(text, values).then((r) => r.rows)`
62
+ * - postgres.js: `(text, values) => sql.unsafe(text, values)`
63
+ *
64
+ * Expired rows are removed a few at a time on each sign-in.
65
+ */
66
+ export declare function createPostgresSessionStore(input: {
67
+ query: (text: string, values: unknown[]) => Promise<Row[]>;
68
+ }): SessionStore;
69
+ export {};
@@ -0,0 +1,119 @@
1
+ /**
2
+ * One process only. For tests and a single-instance development server: two
3
+ * replicas with separate memory stores do not share revocations.
4
+ */
5
+ export function createMemorySessionStore() {
6
+ const records = new Map();
7
+ const sweep = (now) => {
8
+ for (const [key, record] of records)
9
+ if (record.expiresAt <= now)
10
+ records.delete(key);
11
+ };
12
+ return {
13
+ async create(record) {
14
+ sweep(Math.floor(Date.now() / 1000));
15
+ if (records.has(record.key))
16
+ throw new Error('session record already exists');
17
+ records.set(record.key, { ...record });
18
+ },
19
+ async get(key) {
20
+ const record = records.get(key);
21
+ return record ? { ...record } : null;
22
+ },
23
+ async advance(key, generation, next) {
24
+ const record = records.get(key);
25
+ if (!record || record.revokedAt !== null || record.generation !== generation)
26
+ return false;
27
+ records.set(key, { ...record, ...next, generation: generation + 1 });
28
+ return true;
29
+ },
30
+ async revoke(key, at) {
31
+ const record = records.get(key);
32
+ if (record && record.revokedAt === null)
33
+ record.revokedAt = at;
34
+ },
35
+ async revokeSubject(context, subject, at) {
36
+ let count = 0;
37
+ for (const record of records.values())
38
+ if (record.context === context && record.subject === subject && record.revokedAt === null) {
39
+ record.revokedAt = at;
40
+ count++;
41
+ }
42
+ return count;
43
+ },
44
+ };
45
+ }
46
+ /** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
47
+ export const POSTGRES_SESSION_TABLE = `create table if not exists wtfalch_auth_sessions (
48
+ key text primary key,
49
+ context text not null,
50
+ subject text not null,
51
+ generation integer not null,
52
+ payload text not null,
53
+ expires_at bigint not null,
54
+ lease_until bigint,
55
+ revoked_at bigint
56
+ );
57
+ create index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);
58
+ create index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);`;
59
+ const nullable = (value) => value === null || value === undefined ? null : Number(value);
60
+ /**
61
+ * Postgres through whatever driver the app already has. `query` runs one
62
+ * parameterised statement and resolves its rows:
63
+ *
64
+ * - node-postgres: `(text, values) => pool.query(text, values).then((r) => r.rows)`
65
+ * - postgres.js: `(text, values) => sql.unsafe(text, values)`
66
+ *
67
+ * Expired rows are removed a few at a time on each sign-in.
68
+ */
69
+ export function createPostgresSessionStore(input) {
70
+ const { query } = input;
71
+ const record = (row) => ({
72
+ key: String(row.key),
73
+ context: String(row.context),
74
+ subject: String(row.subject),
75
+ generation: Number(row.generation),
76
+ payload: String(row.payload),
77
+ expiresAt: Number(row.expires_at),
78
+ leaseUntil: nullable(row.lease_until),
79
+ revokedAt: nullable(row.revoked_at),
80
+ });
81
+ return {
82
+ async create(r) {
83
+ await query(`delete from wtfalch_auth_sessions where key in
84
+ (select key from wtfalch_auth_sessions where expires_at <= $1 limit 100)`, [Math.floor(Date.now() / 1000)]);
85
+ await query(`insert into wtfalch_auth_sessions
86
+ (key, context, subject, generation, payload, expires_at, lease_until, revoked_at)
87
+ values ($1, $2, $3, $4, $5, $6, $7, $8)`, [
88
+ r.key,
89
+ r.context,
90
+ r.subject,
91
+ r.generation,
92
+ r.payload,
93
+ r.expiresAt,
94
+ r.leaseUntil,
95
+ r.revokedAt,
96
+ ]);
97
+ },
98
+ async get(key) {
99
+ const rows = await query('select * from wtfalch_auth_sessions where key = $1', [key]);
100
+ return rows[0] ? record(rows[0]) : null;
101
+ },
102
+ async advance(key, generation, next) {
103
+ const rows = await query(`update wtfalch_auth_sessions
104
+ set payload = $3, expires_at = $4, lease_until = $5, generation = generation + 1
105
+ where key = $1 and generation = $2 and revoked_at is null
106
+ returning key`, [key, generation, next.payload, next.expiresAt, next.leaseUntil]);
107
+ return rows.length === 1;
108
+ },
109
+ async revoke(key, at) {
110
+ await query('update wtfalch_auth_sessions set revoked_at = $2 where key = $1 and revoked_at is null', [key, at]);
111
+ },
112
+ async revokeSubject(context, subject, at) {
113
+ const rows = await query(`update wtfalch_auth_sessions set revoked_at = $3
114
+ where context = $1 and subject = $2 and revoked_at is null
115
+ returning key`, [context, subject, at]);
116
+ return rows.length;
117
+ },
118
+ };
119
+ }