@voltro/plugin-auth 0.33.0 → 0.34.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/CHANGELOG.md +1801 -0
- package/dist/index.d.ts +830 -26
- package/dist/index.js +182 -52
- package/dist/password.d.ts +2 -1
- package/dist/password.js +44 -35
- package/dist/plugin-BZm5Semm.js +1330 -0
- package/dist/plugin.d.ts +315 -44
- package/dist/plugin.js +2 -1
- package/dist/schema.d.ts +46 -1
- package/dist/schema.js +33 -3
- package/dist/session.d.ts +13 -5
- package/dist/{strategy-Ce4FlGi4.js → strategy-Bty6_XUj.js} +84 -12
- package/dist/strategy.d.ts +111 -1
- package/dist/strategy.js +1 -1
- package/dist/tokens.d.ts +36 -1
- package/dist/tokens.js +15 -8
- package/package.json +3 -3
- package/dist/plugin-ZXs8LFn0.js +0 -820
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
import { AuthStrategy } from '@voltro/protocol';
|
|
2
|
+
import { ConnectionCredential } from '@voltro/protocol';
|
|
2
3
|
import { Effect } from 'effect';
|
|
3
4
|
import { KeyedSecret } from '@voltro/protocol/session';
|
|
4
5
|
import { SessionSecrets } from '@voltro/protocol/session';
|
|
5
6
|
import { SqlClient } from '@effect/sql/SqlClient';
|
|
6
7
|
import { Subject } from '@voltro/protocol';
|
|
8
|
+
import { SubjectIdentity } from '@voltro/protocol';
|
|
7
9
|
import { VerifyOptions } from '@voltro/protocol/session';
|
|
8
10
|
import { VerifyResult } from '@voltro/protocol/session';
|
|
9
11
|
import { VoidIfEmpty } from 'effect/Types';
|
|
10
12
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
11
13
|
import { YieldableError } from 'effect/Cause';
|
|
12
14
|
|
|
15
|
+
export declare interface AcceptInvitationInput {
|
|
16
|
+
readonly token: string;
|
|
17
|
+
/** The authenticated caller's id. Absent means nobody is signed in. */
|
|
18
|
+
readonly userId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export declare interface AcceptInvitationWithSignUpInput {
|
|
22
|
+
readonly token: string;
|
|
23
|
+
readonly password: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
export declare type AssertionOutcome = {
|
|
14
27
|
readonly ok: true;
|
|
15
28
|
readonly result: AssertionResult;
|
|
@@ -94,6 +107,17 @@ export declare interface AuthConfig {
|
|
|
94
107
|
/** Injectable clock (epoch-ms) for the lockout counter. Default `Date.now`;
|
|
95
108
|
* tests pass a controllable one to exercise window/lock expiry. */
|
|
96
109
|
readonly now?: () => number;
|
|
110
|
+
/**
|
|
111
|
+
* Email verification. OFF by default (`policy: 'off'`) — the endpoints and
|
|
112
|
+
* the `emailVerifiedAt` column exist, nothing is gated.
|
|
113
|
+
*
|
|
114
|
+
* `'strict'` is enforced through `subjectGuards`, so it covers every path
|
|
115
|
+
* that issues a session; `authRoutesPlugin` installs that guard for you.
|
|
116
|
+
* Hand-wiring the handlers? Add `emailVerificationGuard(config)` to
|
|
117
|
+
* `subjectGuards` yourself — setting the policy alone changes only what the
|
|
118
|
+
* session is MARKED with, not who may log in.
|
|
119
|
+
*/
|
|
120
|
+
readonly emailVerification?: EmailVerificationConfig;
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
/**
|
|
@@ -128,13 +152,36 @@ export declare interface AuthRoutesPluginOptions extends AuthConfig {
|
|
|
128
152
|
* enrolment routes. */
|
|
129
153
|
readonly mfa?: MfaConfig;
|
|
130
154
|
/** Rebind hook for switch-tenant on the live WS path — pass
|
|
131
|
-
* `
|
|
155
|
+
* `bindConnectionCredential` from `@voltro/runtime`. It receives the cookie
|
|
156
|
+
* this plugin just minted, not a Subject: the connection then presents the
|
|
157
|
+
* same credential a reconnecting browser would, and the auth chain judges it
|
|
158
|
+
* the same way (revocation included). */
|
|
132
159
|
readonly rebind?: RebindConnection;
|
|
133
160
|
/** Route prefix. Default `/auth`. */
|
|
134
161
|
readonly prefix?: string;
|
|
135
162
|
/** Tuning for the request-time session-revocation check (TTL cache
|
|
136
163
|
* window, default 30s; `now` is a testing seam). */
|
|
137
164
|
readonly sessionRevocation?: SessionRevocationOptions;
|
|
165
|
+
/**
|
|
166
|
+
* Tenant invitations. PRESENT (even as `{}`) mounts the five
|
|
167
|
+
* `/auth/invitations/*` routes and arms the retention bound on the
|
|
168
|
+
* `invitations` table; ABSENT mounts nothing.
|
|
169
|
+
*
|
|
170
|
+
* Opt-in by presence, like `mfa` and `passkey`, and for the same reason:
|
|
171
|
+
* the table ships in `authTables` but an app that has not spread it would
|
|
172
|
+
* otherwise get 500s from a route it never asked for.
|
|
173
|
+
*/
|
|
174
|
+
readonly invitations?: InvitationsConfig;
|
|
175
|
+
/**
|
|
176
|
+
* User impersonation ("log in as"). PRESENT mounts `/auth/impersonate/start`
|
|
177
|
+
* + `/stop`, arms the retention bound on `impersonationGrants`, and closes
|
|
178
|
+
* the account-security routes to impersonated sessions; ABSENT mounts
|
|
179
|
+
* nothing and the block below cannot fire.
|
|
180
|
+
*
|
|
181
|
+
* Both `authority` and `audit` are REQUIRED — there is no way to switch this
|
|
182
|
+
* on while leaving either unanswered. See `ImpersonationConfig`.
|
|
183
|
+
*/
|
|
184
|
+
readonly impersonation?: ImpersonationConfig;
|
|
138
185
|
/** Disambiguates multiple instances of this plugin in one app. */
|
|
139
186
|
readonly name?: string;
|
|
140
187
|
}
|
|
@@ -157,6 +204,15 @@ export declare const clearSessionCookie: (options?: IssueSessionOptions) => stri
|
|
|
157
204
|
* rotation env vars. */
|
|
158
205
|
export declare const configSessionSecrets: (config: AuthConfig) => SessionSecrets;
|
|
159
206
|
|
|
207
|
+
export declare interface CreateInvitationInput {
|
|
208
|
+
/** The authenticated caller — resolved from their session, never from the
|
|
209
|
+
* request body. */
|
|
210
|
+
readonly inviterUserId: string;
|
|
211
|
+
readonly tenantId: string;
|
|
212
|
+
readonly email: string;
|
|
213
|
+
readonly role: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
export declare const CSRF_COOKIE_NAME: "voltro:csrf";
|
|
161
217
|
|
|
162
218
|
export declare const CSRF_HEADER_NAME: "x-csrf-token";
|
|
@@ -180,6 +236,122 @@ export declare const CSRF_HEADER_NAME: "x-csrf-token";
|
|
|
180
236
|
*/
|
|
181
237
|
export declare const dataStoreChallengeStore: (sql: SqlClient) => ChallengeStore;
|
|
182
238
|
|
|
239
|
+
/** Default set of roles allowed to invite at all. */
|
|
240
|
+
export declare const DEFAULT_INVITER_ROLES: ReadonlyArray<string>;
|
|
241
|
+
|
|
242
|
+
/** Default role ordering, most privileged first. */
|
|
243
|
+
export declare const DEFAULT_ROLE_RANK: ReadonlyArray<string>;
|
|
244
|
+
|
|
245
|
+
/** The shipped rank-based rule. Exported so an app extending it can call it. */
|
|
246
|
+
export declare const defaultCanGrantRole: (ctx: GrantRoleContext, rank?: ReadonlyArray<string>) => boolean;
|
|
247
|
+
|
|
248
|
+
/** The 403 body's `error` when `'strict'` refuses a login. */
|
|
249
|
+
export declare const EMAIL_NOT_VERIFIED_CODE: "email_not_verified";
|
|
250
|
+
|
|
251
|
+
/** The subject-metadata key carrying the verification mark. */
|
|
252
|
+
export declare const EMAIL_VERIFIED_METADATA_KEY: "emailVerified";
|
|
253
|
+
|
|
254
|
+
/** Default gap between two verification emails to one user. */
|
|
255
|
+
export declare const EMAIL_VERIFY_RESEND_COOLDOWN_S = 60;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Default email-verification lifetime — 24 hours.
|
|
259
|
+
*
|
|
260
|
+
* Longer than the other two on purpose, and the asymmetry is the point. A
|
|
261
|
+
* magic link and a reset link are CREDENTIALS: whoever holds one signs in, so
|
|
262
|
+
* a short fuse is worth the friction of asking for another. A verification
|
|
263
|
+
* link grants nothing — redeeming it only asserts that the address reaches its
|
|
264
|
+
* owner — so the cost of a short lifetime is all friction and no security, paid
|
|
265
|
+
* by exactly the users who check mail once a day.
|
|
266
|
+
*/
|
|
267
|
+
export declare const EMAIL_VERIFY_TTL_S: number;
|
|
268
|
+
|
|
269
|
+
/** Whether the verification feature does anything at all for this app. */
|
|
270
|
+
export declare const emailVerificationActive: (config: EmailVerificationConfig | undefined) => boolean;
|
|
271
|
+
|
|
272
|
+
export declare interface EmailVerificationConfig {
|
|
273
|
+
/** Default `'off'` — see the header for why a stricter default is an
|
|
274
|
+
* outage rather than a safer choice. */
|
|
275
|
+
readonly policy?: EmailVerificationPolicy;
|
|
276
|
+
/** Lifetime of a verification link. Default {@link EMAIL_VERIFY_TTL_S} (24h). */
|
|
277
|
+
readonly ttlSeconds?: number;
|
|
278
|
+
/** Path the emailed link points at. Default `/auth/verify-email/callback`. */
|
|
279
|
+
readonly callbackPath?: string;
|
|
280
|
+
/**
|
|
281
|
+
* Minimum gap between two verification emails to the same user. Default 60s.
|
|
282
|
+
*
|
|
283
|
+
* The only bound on an endpoint that sends mail to an address chosen by an
|
|
284
|
+
* unauthenticated caller. Without it, "resend" is a mail-bomb primitive
|
|
285
|
+
* pointed at any address the attacker knows has an account, and the cost is
|
|
286
|
+
* carried by your sending reputation.
|
|
287
|
+
*/
|
|
288
|
+
readonly resendCooldownSeconds?: number;
|
|
289
|
+
/** Send the verification email automatically on sign-up. Default: true
|
|
290
|
+
* whenever `policy` is not `'off'`. */
|
|
291
|
+
readonly sendOnSignUp?: boolean;
|
|
292
|
+
/**
|
|
293
|
+
* Accounts created STRICTLY BEFORE this instant count as verified.
|
|
294
|
+
*
|
|
295
|
+
* The migration seam, and the reason `'strict'` is adoptable at all: an app
|
|
296
|
+
* turning the policy on has a table full of `emailVerifiedAt IS NULL` rows
|
|
297
|
+
* belonging to real users who did nothing wrong. Set this to the deploy
|
|
298
|
+
* instant and only accounts created from then on have to prove anything.
|
|
299
|
+
*
|
|
300
|
+
* It is deliberately a CONFIG value rather than a backfill we run for you:
|
|
301
|
+
* an app that genuinely wants its existing users to re-verify says so by
|
|
302
|
+
* leaving this unset, and one that does not is spared writing to every row
|
|
303
|
+
* of its users table.
|
|
304
|
+
*/
|
|
305
|
+
readonly exemptAccountsCreatedBefore?: Date;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The `AuthConfig` slice these handlers read. Declared structurally so this
|
|
309
|
+
* module does not import `handlers.ts` (which imports this one). */
|
|
310
|
+
export declare interface EmailVerificationDeps {
|
|
311
|
+
readonly appBaseUrl?: string;
|
|
312
|
+
readonly sendEmail?: (input: {
|
|
313
|
+
readonly to: string;
|
|
314
|
+
readonly subject: string;
|
|
315
|
+
readonly html: string;
|
|
316
|
+
readonly text: string;
|
|
317
|
+
readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation';
|
|
318
|
+
readonly actionUrl: string;
|
|
319
|
+
}) => Promise<void>;
|
|
320
|
+
readonly emailVerification?: EmailVerificationConfig;
|
|
321
|
+
readonly now?: () => number;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The `'strict'` enforcement, as a post-authentication subject guard.
|
|
326
|
+
*
|
|
327
|
+
* A guard rather than a check inside `handleSignIn` on purpose: the seam
|
|
328
|
+
* already runs at EVERY path that grants a session to a pre-existing user
|
|
329
|
+
* (password, MFA verify, magic-link consume, passkey assertion), so wiring it
|
|
330
|
+
* here covers all four and cannot be forgotten on the fifth. `authRoutesPlugin`
|
|
331
|
+
* appends it automatically under `'strict'`; an app hand-wiring the handlers
|
|
332
|
+
* adds it to `subjectGuards` itself.
|
|
333
|
+
*
|
|
334
|
+
* Under `'off'` and `'soft'` it returns a guard that always allows, rather
|
|
335
|
+
* than the caller having to decide whether to install one — a policy read in
|
|
336
|
+
* two places is a policy that can disagree with itself.
|
|
337
|
+
*/
|
|
338
|
+
export declare const emailVerificationGuard: (config: EmailVerificationConfig | undefined) => SubjectGuard;
|
|
339
|
+
|
|
340
|
+
/** What an account with an unproven address may do. See the header. */
|
|
341
|
+
export declare type EmailVerificationPolicy = 'off' | 'soft' | 'strict';
|
|
342
|
+
|
|
343
|
+
export declare interface EmailVerifyConfirmInput {
|
|
344
|
+
readonly token: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export declare interface EmailVerifyRequestInput {
|
|
348
|
+
/** The address to send to. Either this or `userId`. */
|
|
349
|
+
readonly email?: string;
|
|
350
|
+
/** The authenticated caller's id — the "resend to me" path, which needs no
|
|
351
|
+
* address in the request and therefore cannot be pointed at someone else. */
|
|
352
|
+
readonly userId?: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
183
355
|
/** Mint a fresh WebAuthn challenge (base64url, 32 random bytes). The
|
|
184
356
|
* server stashes it (keyed by user / ceremony) and hands it to the
|
|
185
357
|
* browser; `verifyRegistration` / `verifyAssertion` check it back. */
|
|
@@ -208,6 +380,14 @@ export declare const generateTotpSecret: () => string;
|
|
|
208
380
|
*/
|
|
209
381
|
export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugin>) => AuthStrategy | null;
|
|
210
382
|
|
|
383
|
+
export declare interface GrantRoleContext {
|
|
384
|
+
/** The inviter's role in THIS tenant, as read from `memberships`. */
|
|
385
|
+
readonly inviterRole: string;
|
|
386
|
+
/** The role the invitation would carry. */
|
|
387
|
+
readonly requestedRole: string;
|
|
388
|
+
readonly tenantId: string;
|
|
389
|
+
}
|
|
390
|
+
|
|
211
391
|
/**
|
|
212
392
|
* Run the config's post-authentication subject guards against a resolved
|
|
213
393
|
* user. Returns a 403 `HandlerResult` (carrying the vetoing guard's `code`
|
|
@@ -221,6 +401,65 @@ export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugi
|
|
|
221
401
|
*/
|
|
222
402
|
export declare const guardSubject: (user: UserRecord, config: AuthConfig) => Effect.Effect<HandlerResult | null>;
|
|
223
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Accept an invitation as an ALREADY-AUTHENTICATED user.
|
|
406
|
+
*
|
|
407
|
+
* The three refusals, in the order they matter:
|
|
408
|
+
*
|
|
409
|
+
* - **401 `authentication_required`** when nobody is signed in. The response
|
|
410
|
+
* carries the invited `email` so the sign-in page can pre-fill it — that
|
|
411
|
+
* address is already in the recipient's mailbox, so echoing it to the
|
|
412
|
+
* holder of the token reveals nothing they do not have.
|
|
413
|
+
* - **403 `invitation_email_mismatch`** when the signed-in user's address is
|
|
414
|
+
* not the invited one. This is the property that makes a leaked or
|
|
415
|
+
* forwarded link useless: without it, the first person to open the link
|
|
416
|
+
* while signed into any account joins the tenant.
|
|
417
|
+
* - **401 `invalid_or_expired_invitation`** for a token that is unknown,
|
|
418
|
+
* spent, revoked or lapsed — one answer for all four, because
|
|
419
|
+
* distinguishing them tells a holder of a dead token which kind of dead.
|
|
420
|
+
*
|
|
421
|
+
* The membership is written with the role from the INVITATION ROW. Note the
|
|
422
|
+
* ordering: the invitation is redeemed FIRST (the single-use UPDATE), and only
|
|
423
|
+
* a winning redemption goes on to write the membership. Reversed, two
|
|
424
|
+
* concurrent accepts would both write.
|
|
425
|
+
*/
|
|
426
|
+
export declare const handleAcceptInvitation: (input: AcceptInvitationInput, store: UserStore, deps?: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Accept an invitation by CREATING the account it was addressed to — the
|
|
430
|
+
* "you've been invited, choose a password" flow.
|
|
431
|
+
*
|
|
432
|
+
* The account's email is taken from the INVITATION, never from the request:
|
|
433
|
+
* an input-supplied address would let the holder of a leaked token create an
|
|
434
|
+
* account under an address of their choosing and have it join the tenant. For
|
|
435
|
+
* the same reason there is no `tenantId` input — it is the invitation's.
|
|
436
|
+
*
|
|
437
|
+
* The new account is created ALREADY VERIFIED: the invitation arrived in that
|
|
438
|
+
* mailbox and came back, which is the same proof a verification link provides.
|
|
439
|
+
*
|
|
440
|
+
* Refuses with 409 when the address already has an account, and directs the
|
|
441
|
+
* caller to sign in and use {@link handleAcceptInvitation} — creating a second
|
|
442
|
+
* account for one address is not a fallback, it is a duplicate identity.
|
|
443
|
+
*/
|
|
444
|
+
export declare const handleAcceptInvitationWithSignUp: (input: AcceptInvitationWithSignUpInput, store: UserStore, deps: InvitationDeps & {
|
|
445
|
+
/** Hash a plaintext password. Injected so this module does not import the
|
|
446
|
+
* password module's Effect error channel into the invitation surface. */
|
|
447
|
+
readonly hashPassword: (plaintext: string) => Effect.Effect<string, unknown>;
|
|
448
|
+
/** Minimum password length, mirroring `handleSignUp`. Default 8. */
|
|
449
|
+
readonly minPasswordLength?: number;
|
|
450
|
+
}) => Effect.Effect<HandlerResultShape_2>;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Create + email an invitation.
|
|
454
|
+
*
|
|
455
|
+
* Refuses when the caller may not invite into this tenant, when the requested
|
|
456
|
+
* role is above what they may grant, or when the tenant is at its pending cap.
|
|
457
|
+
* A re-invite SUPERSEDES: every pending invitation for the same (tenant,
|
|
458
|
+
* email) is revoked first, so the address never holds two live tokens and
|
|
459
|
+
* "resend" cannot be used to accumulate them.
|
|
460
|
+
*/
|
|
461
|
+
export declare const handleCreateInvitation: (input: CreateInvitationInput, store: UserStore, deps: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
|
|
462
|
+
|
|
224
463
|
/**
|
|
225
464
|
* Issue a CSRF token. Returns the token in the JSON body AND sets a
|
|
226
465
|
* readable (non-HttpOnly) `voltro:csrf` cookie. The SPA reads the cookie
|
|
@@ -229,6 +468,52 @@ export declare const guardSubject: (user: UserRecord, config: AuthConfig) => Eff
|
|
|
229
468
|
*/
|
|
230
469
|
export declare const handleCsrf: (config: AuthConfig) => HandlerResult;
|
|
231
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Redeem a verification link.
|
|
473
|
+
*
|
|
474
|
+
* Issues NO session — see the header. The response says whether the address is
|
|
475
|
+
* now verified, and the client sends the user to sign in.
|
|
476
|
+
*/
|
|
477
|
+
export declare const handleEmailVerifyConfirm: (input: EmailVerifyConfirmInput, store: UserStore) => Effect.Effect<HandlerResultShape>;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Request (or re-request) a verification link.
|
|
481
|
+
*
|
|
482
|
+
* Always 202, whether or not the address has an account and whether or not the
|
|
483
|
+
* cooldown suppressed the send — same reasoning as magic-link and
|
|
484
|
+
* password-reset. Two things leak from a status that varies: which addresses
|
|
485
|
+
* are registered, and (through the cooldown) whether one was recently active.
|
|
486
|
+
* An already-verified user is also answered 202 and sent nothing.
|
|
487
|
+
*/
|
|
488
|
+
export declare const handleEmailVerifyRequest: (input: EmailVerifyRequestInput, store: UserStore, deps: EmailVerificationDeps) => Effect.Effect<HandlerResultShape>;
|
|
489
|
+
|
|
490
|
+
export declare const handleImpersonationStart: (input: ImpersonationStartInput, store: UserStore, deps: ImpersonationDeps) => Effect.Effect<HandlerResultShape_3>;
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* End an impersonation and put the impersonator back in their own identity.
|
|
494
|
+
*
|
|
495
|
+
* Three things happen, in this order, and all three matter:
|
|
496
|
+
*
|
|
497
|
+
* 1. the grant is CLOSED (`endedAt`), so the record says when it stopped;
|
|
498
|
+
* 2. the impersonated `sessions` row is DELETED and dropped from the
|
|
499
|
+
* revocation cache — so a copy of that cookie taken during the grant is
|
|
500
|
+
* dead immediately, not at its own expiry;
|
|
501
|
+
* 3. the actor's ORIGINAL session row (never revoked, still ticking) gets a
|
|
502
|
+
* fresh cookie for its REMAINING lifetime.
|
|
503
|
+
*
|
|
504
|
+
* (3) is why the actor's session is left alive throughout. Minting them a new
|
|
505
|
+
* session instead would silently extend their login by a full TTL every time
|
|
506
|
+
* they used the support tool. If their own session has expired or been revoked
|
|
507
|
+
* meanwhile, the response clears the cookie — being signed out is the correct
|
|
508
|
+
* outcome, and it is strictly better than staying signed in as somebody else.
|
|
509
|
+
*/
|
|
510
|
+
export declare const handleImpersonationStop: (input: ImpersonationStopInput, store: UserStore, deps: ImpersonationDeps) => Effect.Effect<HandlerResultShape_3>;
|
|
511
|
+
|
|
512
|
+
export declare const handleListInvitations: (input: {
|
|
513
|
+
readonly callerUserId: string;
|
|
514
|
+
readonly tenantId: string;
|
|
515
|
+
}, store: UserStore, deps: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
|
|
516
|
+
|
|
232
517
|
export declare const handleListMemberships: (input: {
|
|
233
518
|
readonly userId: string;
|
|
234
519
|
}, store: UserStore) => Effect.Effect<HandlerResult>;
|
|
@@ -292,8 +577,26 @@ export declare const handlePasswordResetConfirm: (input: PasswordResetConfirmInp
|
|
|
292
577
|
* a single-use reset token when the user exists. */
|
|
293
578
|
export declare const handlePasswordResetRequest: (input: PasswordResetRequestInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
|
|
294
579
|
|
|
580
|
+
/**
|
|
581
|
+
* What is behind this link, without redeeming it — the accept page's read.
|
|
582
|
+
*
|
|
583
|
+
* Deliberately unauthenticated and deliberately thin: tenant, role, the
|
|
584
|
+
* invited address and whether it is still live. It does NOT reveal whether an
|
|
585
|
+
* account already exists for the address, because that would turn a leaked
|
|
586
|
+
* invitation into an account-existence oracle for a third party.
|
|
587
|
+
*/
|
|
588
|
+
export declare const handlePreviewInvitation: (input: {
|
|
589
|
+
readonly token: string;
|
|
590
|
+
}, store: UserStore, deps?: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
|
|
591
|
+
|
|
295
592
|
export declare const handleRevokeAllOtherSessions: (input: RevokeOtherSessionsInput, store: UserStore) => Effect.Effect<HandlerResult>;
|
|
296
593
|
|
|
594
|
+
export declare const handleRevokeInvitation: (input: {
|
|
595
|
+
readonly callerUserId: string;
|
|
596
|
+
readonly tenantId: string;
|
|
597
|
+
readonly invitationId: string;
|
|
598
|
+
}, store: UserStore, deps: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
|
|
599
|
+
|
|
297
600
|
export declare const handleRevokeSession: (input: RevokeSessionInput, store: UserStore) => Effect.Effect<HandlerResult>;
|
|
298
601
|
|
|
299
602
|
export declare interface HandlerResult {
|
|
@@ -304,6 +607,26 @@ export declare interface HandlerResult {
|
|
|
304
607
|
readonly location?: string;
|
|
305
608
|
}
|
|
306
609
|
|
|
610
|
+
declare interface HandlerResultShape {
|
|
611
|
+
readonly status: number;
|
|
612
|
+
readonly body: string;
|
|
613
|
+
readonly contentType?: string;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
declare interface HandlerResultShape_2 {
|
|
617
|
+
readonly status: number;
|
|
618
|
+
readonly body: string;
|
|
619
|
+
readonly contentType?: string;
|
|
620
|
+
readonly setCookie?: string;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
declare interface HandlerResultShape_3 {
|
|
624
|
+
readonly status: number;
|
|
625
|
+
readonly body: string;
|
|
626
|
+
readonly contentType?: string;
|
|
627
|
+
readonly setCookie?: string;
|
|
628
|
+
}
|
|
629
|
+
|
|
307
630
|
/**
|
|
308
631
|
* Sign-in handler. Accepts either form-encoded OR JSON input with
|
|
309
632
|
* `email` + `password`. Returns:
|
|
@@ -347,10 +670,15 @@ export declare const handleSignUp: (input: SignUpInput, store: UserStore, config
|
|
|
347
670
|
|
|
348
671
|
/**
|
|
349
672
|
* Switch the active tenant. Validates the user is actually a member of
|
|
350
|
-
* `targetTenantId` (the switch-tenant guard), re-issues the session
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
673
|
+
* `targetTenantId` (the switch-tenant guard), re-issues the session cookie
|
|
674
|
+
* with the new active tenant, and — when a `clientId` + `rebind` are supplied —
|
|
675
|
+
* presents that new cookie on the live connection so its subsequent calls
|
|
676
|
+
* resolve against the target tenant without a reconnect.
|
|
677
|
+
*
|
|
678
|
+
* Calls only. Subscriptions already open on that connection were authorized
|
|
679
|
+
* under the previous cookie and keep running until the client re-subscribes;
|
|
680
|
+
* nothing re-scopes them, and nothing ever did — the `onBindConnectionSubject`
|
|
681
|
+
* listener that claimed to had no subscribers in the entire framework.
|
|
354
682
|
*/
|
|
355
683
|
export declare const handleSwitchTenant: (input: SwitchTenantInput, store: UserStore, config: AuthConfig, rebind?: RebindConnection) => Effect.Effect<HandlerResult>;
|
|
356
684
|
|
|
@@ -366,10 +694,324 @@ export declare const hashPassword: (plaintext: string) => Effect.Effect<string,
|
|
|
366
694
|
* brute-force surface that would need a slow KDF. */
|
|
367
695
|
export declare const hashToken: (plaintext: string) => string;
|
|
368
696
|
|
|
697
|
+
/** Default grant lifetime — 15 minutes. A support interaction, not a shift. */
|
|
698
|
+
export declare const IMPERSONATION_DEFAULT_DURATION_S: number;
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Sub-paths of the auth surface that an IMPERSONATED session may not reach.
|
|
702
|
+
*
|
|
703
|
+
* Every one of them either mints a durable credential on the account (MFA,
|
|
704
|
+
* passkeys), destroys the impersonator's way back (revoke-others, which would
|
|
705
|
+
* kill the actor's own restored-to session), moves the session somewhere the
|
|
706
|
+
* grant does not describe (switch-tenant), or extends the grant (a second
|
|
707
|
+
* impersonation). Suffixes, matched against the plugin's configured prefix.
|
|
708
|
+
*/
|
|
709
|
+
export declare const IMPERSONATION_FORBIDDEN_SUBPATHS: ReadonlyArray<string>;
|
|
710
|
+
|
|
711
|
+
/** The subject-metadata key carrying the mark. */
|
|
712
|
+
export declare const IMPERSONATION_METADATA_KEY: "impersonation";
|
|
713
|
+
|
|
714
|
+
export declare interface ImpersonationAuditEvent {
|
|
715
|
+
readonly kind: ImpersonationAuditKind;
|
|
716
|
+
readonly at: Date;
|
|
717
|
+
/** The impersonator. Null only when the attempt had no resolvable caller. */
|
|
718
|
+
readonly actorUserId: string | null;
|
|
719
|
+
readonly actorEmail: string | null;
|
|
720
|
+
/** The impersonated user. Null on a refusal that never resolved one. */
|
|
721
|
+
readonly targetUserId: string | null;
|
|
722
|
+
readonly targetEmail: string | null;
|
|
723
|
+
readonly tenantId: string | null;
|
|
724
|
+
/** The impersonated session, once one exists. */
|
|
725
|
+
readonly sessionId?: string;
|
|
726
|
+
readonly grantId?: string;
|
|
727
|
+
readonly reason?: string | null;
|
|
728
|
+
readonly expiresAt?: Date;
|
|
729
|
+
/** The machine code on `kind: 'refused'` — `self`, `nested`, `not_permitted`,
|
|
730
|
+
* `target_may_impersonate`, `reason_required`, `target_not_found`. */
|
|
731
|
+
readonly refusal?: string;
|
|
732
|
+
readonly ipAddress?: string | null;
|
|
733
|
+
readonly userAgent?: string | null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** What happened, for the audit sink. `refused` is recorded too: an attempt
|
|
737
|
+
* that was turned away is the single most interesting row in this table. */
|
|
738
|
+
export declare type ImpersonationAuditKind = 'started' | 'stopped' | 'refused';
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* A `redactSubject` function for `@voltro/plugin-audit` that keeps the
|
|
742
|
+
* impersonation mark and redacts the rest of `subject.metadata`.
|
|
743
|
+
*
|
|
744
|
+
* **Wire this, or your audit rows will not say who was behind an impersonated
|
|
745
|
+
* action.** `auditPlugin`'s default is `redactSubject: 'metadata'`, which
|
|
746
|
+
* replaces the whole metadata bag — correct in general (that bag is where a
|
|
747
|
+
* per-user provider credential lands, and an audit table is the last place a
|
|
748
|
+
* live PAT should be) and it takes this mark with it. The result is a trail in
|
|
749
|
+
* which an impersonated action is indistinguishable from the user's own, which
|
|
750
|
+
* is the exact failure this module exists to prevent.
|
|
751
|
+
*
|
|
752
|
+
* auditPlugin({
|
|
753
|
+
* sink: 'datastore',
|
|
754
|
+
* redactSubject: impersonationAuditRedactor(),
|
|
755
|
+
* })
|
|
756
|
+
*
|
|
757
|
+
* It is strictly safer than `redactSubject: 'none'` (which would store the
|
|
758
|
+
* whole bag, credentials included) and strictly more informative than the
|
|
759
|
+
* default. The `impersonationGrants` row remains the record that does not
|
|
760
|
+
* depend on anyone wiring this.
|
|
761
|
+
*/
|
|
762
|
+
export declare const impersonationAuditRedactor: () => (subject: Subject) => unknown;
|
|
763
|
+
|
|
764
|
+
export declare interface ImpersonationAuthorityInput {
|
|
765
|
+
/** The caller's resolved subject. */
|
|
766
|
+
readonly actor: Subject;
|
|
767
|
+
/** The caller's user row. */
|
|
768
|
+
readonly actorUser: UserRecord;
|
|
769
|
+
/** The user they are asking to act as. */
|
|
770
|
+
readonly target: UserRecord;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export declare interface ImpersonationConfig {
|
|
774
|
+
/**
|
|
775
|
+
* MAY this actor impersonate this target? REQUIRED — see the header for why
|
|
776
|
+
* there is no default and why it must not be a scope check.
|
|
777
|
+
*
|
|
778
|
+
* It is called TWICE per start: once as asked, and once with the identities
|
|
779
|
+
* SWAPPED to answer "could the target impersonate the actor?". A `true` from
|
|
780
|
+
* the second call refuses the request (the PEER rule). Write it as a pure
|
|
781
|
+
* predicate over the two records; a function whose answer depends on which
|
|
782
|
+
* request is in flight will make that probe meaningless.
|
|
783
|
+
*/
|
|
784
|
+
readonly authority: (input: ImpersonationAuthorityInput) => Effect.Effect<boolean>;
|
|
785
|
+
/**
|
|
786
|
+
* Where impersonation events go. REQUIRED, and required precisely because
|
|
787
|
+
* this is the feature whose whole risk is an unrecorded action: a config
|
|
788
|
+
* that let you enable impersonation while leaving the destination unset
|
|
789
|
+
* would make the dangerous half optional and the safe half opt-in.
|
|
790
|
+
*
|
|
791
|
+
* Called for `started`, `stopped` AND `refused`. A throwing or rejecting
|
|
792
|
+
* sink is swallowed — a broken log must not leave a grant half-created —
|
|
793
|
+
* so treat this as best-effort ON TOP of the `impersonationGrants` row,
|
|
794
|
+
* which is written transactionally with the session and is not.
|
|
795
|
+
*/
|
|
796
|
+
readonly audit: (event: ImpersonationAuditEvent) => Effect.Effect<void> | Promise<void> | void;
|
|
797
|
+
/**
|
|
798
|
+
* Hard ceiling on one grant, in seconds. Default 900 (15 minutes).
|
|
799
|
+
*
|
|
800
|
+
* A caller may request LESS and never more — a requested duration is
|
|
801
|
+
* clamped, not validated, because a support tool asking for eight hours
|
|
802
|
+
* should get fifteen minutes rather than an error it will retry around.
|
|
803
|
+
* The number is the impersonated cookie's TTL as well as the grant row's
|
|
804
|
+
* `expiresAt`, so the bound survives the process that issued it.
|
|
805
|
+
*/
|
|
806
|
+
readonly maxDurationSeconds?: number;
|
|
807
|
+
/** Require a non-empty `reason` on every start. Default `true` — a support
|
|
808
|
+
* action with no stated cause is the one you cannot review later. */
|
|
809
|
+
readonly requireReason?: boolean;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export declare interface ImpersonationDeps {
|
|
813
|
+
readonly impersonation: ImpersonationConfig;
|
|
814
|
+
readonly issuer: ImpersonationSessionIssuer;
|
|
815
|
+
readonly now?: () => number;
|
|
816
|
+
/** Drop a session id from the request-time revocation cache, so ending a
|
|
817
|
+
* grant kills the cookie on THIS process immediately rather than within the
|
|
818
|
+
* cache window. */
|
|
819
|
+
readonly invalidateSession?: (sessionId: string) => void;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** One impersonation grant — "actor is acting as target, on this session,
|
|
823
|
+
* until this instant". See `impersonationGrantsTable`. */
|
|
824
|
+
export declare interface ImpersonationGrantRecord {
|
|
825
|
+
readonly id: string;
|
|
826
|
+
/** The IMPERSONATED session's row id. */
|
|
827
|
+
readonly sessionId: string;
|
|
828
|
+
readonly actorUserId: string;
|
|
829
|
+
/** The impersonator's own session, restored when the grant ends. */
|
|
830
|
+
readonly actorSessionId: string;
|
|
831
|
+
readonly targetUserId: string;
|
|
832
|
+
readonly tenantId: string;
|
|
833
|
+
readonly reason: string | null;
|
|
834
|
+
readonly startedAt: Date;
|
|
835
|
+
readonly expiresAt: Date;
|
|
836
|
+
readonly endedAt: Date | null;
|
|
837
|
+
readonly endedReason: string | null;
|
|
838
|
+
readonly ipAddress?: string | null;
|
|
839
|
+
readonly userAgent?: string | null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** The mark carried on an impersonated session's Subject. Plain JSON — it is
|
|
843
|
+
* serialised into a cookie payload and into audit rows. */
|
|
844
|
+
export declare interface ImpersonationMark {
|
|
845
|
+
/** The impersonating user. */
|
|
846
|
+
readonly actorUserId: string;
|
|
847
|
+
/** Their own session, restored when the grant ends. */
|
|
848
|
+
readonly actorSessionId: string;
|
|
849
|
+
readonly grantId: string;
|
|
850
|
+
/** ISO-8601. */
|
|
851
|
+
readonly startedAt: string;
|
|
852
|
+
/** ISO-8601. Equals the cookie's own expiry — one number, two places. */
|
|
853
|
+
readonly expiresAt: string;
|
|
854
|
+
readonly reason?: string;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** The impersonation mark on a Subject, or null for an ordinary session. */
|
|
858
|
+
export declare const impersonationOf: (subject: Subject) => ImpersonationMark | null;
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* How a session is minted. Injected rather than imported so this module has no
|
|
862
|
+
* edge to `handlers.ts` — and, more usefully, so a test can observe exactly
|
|
863
|
+
* what TTL and what metadata the impersonated cookie was built with.
|
|
864
|
+
*/
|
|
865
|
+
export declare interface ImpersonationSessionIssuer {
|
|
866
|
+
readonly issue: (user: UserRecord, options: {
|
|
867
|
+
readonly sessionId: string;
|
|
868
|
+
readonly ttlSeconds: number;
|
|
869
|
+
readonly metadata: Record<string, unknown>;
|
|
870
|
+
readonly ipAddress?: string | null;
|
|
871
|
+
readonly userAgent?: string | null;
|
|
872
|
+
}) => Effect.Effect<{
|
|
873
|
+
readonly setCookie: string;
|
|
874
|
+
readonly subject: Subject;
|
|
875
|
+
}>;
|
|
876
|
+
/** Re-issue a cookie for an EXISTING session row — the restore on stop. */
|
|
877
|
+
readonly reissue: (user: UserRecord, options: {
|
|
878
|
+
readonly sessionId: string;
|
|
879
|
+
readonly ttlSeconds: number;
|
|
880
|
+
}) => Effect.Effect<{
|
|
881
|
+
readonly setCookie: string;
|
|
882
|
+
readonly subject: Subject;
|
|
883
|
+
}>;
|
|
884
|
+
/** A `Set-Cookie` that clears the session — the fallback when the actor's
|
|
885
|
+
* own session no longer exists to restore. */
|
|
886
|
+
readonly clear: () => string;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export declare interface ImpersonationStartInput {
|
|
890
|
+
/** The caller's resolved subject — from the session cookie, never the body. */
|
|
891
|
+
readonly actorSubject: Subject;
|
|
892
|
+
/** The caller's own server-side session id. Restored when the grant ends. */
|
|
893
|
+
readonly actorSessionId: string;
|
|
894
|
+
/** Who to act as. One of the two; `userId` wins when both are given. */
|
|
895
|
+
readonly targetUserId?: string;
|
|
896
|
+
readonly targetEmail?: string;
|
|
897
|
+
readonly reason?: string;
|
|
898
|
+
/** Requested duration. CLAMPED to `maxDurationSeconds`, never exceeded. */
|
|
899
|
+
readonly durationSeconds?: number;
|
|
900
|
+
readonly ipAddress?: string | null;
|
|
901
|
+
readonly userAgent?: string | null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export declare interface ImpersonationStopInput {
|
|
905
|
+
/** The caller's resolved subject — must be an impersonated one. */
|
|
906
|
+
readonly subject: Subject;
|
|
907
|
+
/** The caller's current (impersonated) session id. */
|
|
908
|
+
readonly sessionId: string;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Default cap on live invitations per tenant. */
|
|
912
|
+
export declare const INVITATION_MAX_PENDING = 500;
|
|
913
|
+
|
|
914
|
+
/** Default invitation lifetime — 7 days. Long enough to survive a weekend and
|
|
915
|
+
* a holiday Monday, short enough that a forgotten mailbox is not a standing
|
|
916
|
+
* key to a tenant. */
|
|
917
|
+
export declare const INVITATION_TTL_S: number;
|
|
918
|
+
|
|
919
|
+
/** The `AuthConfig` slice these handlers read. Structural so this module does
|
|
920
|
+
* not import `handlers.ts`. */
|
|
921
|
+
export declare interface InvitationDeps {
|
|
922
|
+
readonly appBaseUrl?: string;
|
|
923
|
+
readonly sendEmail?: (input: {
|
|
924
|
+
readonly to: string;
|
|
925
|
+
readonly subject: string;
|
|
926
|
+
readonly html: string;
|
|
927
|
+
readonly text: string;
|
|
928
|
+
readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation';
|
|
929
|
+
readonly actionUrl: string;
|
|
930
|
+
}) => Promise<void>;
|
|
931
|
+
readonly invitations?: InvitationsConfig;
|
|
932
|
+
readonly now?: () => number;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* A tenant invitation — the grant that turns into a `memberships` row when
|
|
937
|
+
* redeemed. See `invitationsTable` for why `role` lives here and not in the
|
|
938
|
+
* accept request.
|
|
939
|
+
*/
|
|
940
|
+
export declare interface InvitationRecord {
|
|
941
|
+
readonly id: string;
|
|
942
|
+
/** SHA-256 hex of the plaintext token. The plaintext lives only in the email. */
|
|
943
|
+
readonly tokenHash: string;
|
|
944
|
+
/** The invited address, lower-cased. */
|
|
945
|
+
readonly email: string;
|
|
946
|
+
readonly tenantId: string;
|
|
947
|
+
/** The role the membership is created with. Chosen by the INVITER. */
|
|
948
|
+
readonly role: string;
|
|
949
|
+
readonly invitedByUserId: string;
|
|
950
|
+
readonly expiresAt: Date;
|
|
951
|
+
readonly acceptedAt: Date | null;
|
|
952
|
+
readonly acceptedByUserId: string | null;
|
|
953
|
+
readonly revokedAt: Date | null;
|
|
954
|
+
readonly revokedByUserId: string | null;
|
|
955
|
+
readonly createdAt: Date;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export declare interface InvitationsConfig {
|
|
959
|
+
/** Lifetime of an invitation. Default {@link INVITATION_TTL_S} (7 days). */
|
|
960
|
+
readonly ttlSeconds?: number;
|
|
961
|
+
/** Path the emailed link points at. Default `/auth/invitations/accept`. */
|
|
962
|
+
readonly callbackPath?: string;
|
|
963
|
+
/**
|
|
964
|
+
* Cap on simultaneously-live invitations per tenant. Default 500.
|
|
965
|
+
*
|
|
966
|
+
* A bound, not a business rule: a compromised admin account otherwise turns
|
|
967
|
+
* the invite endpoint into a mail cannon aimed at any list of addresses, one
|
|
968
|
+
* row and one message each, with your domain as the sender.
|
|
969
|
+
*/
|
|
970
|
+
readonly maxPending?: number;
|
|
971
|
+
/** Roles permitted to create / list / revoke invitations. Default
|
|
972
|
+
* `['owner', 'admin']`. */
|
|
973
|
+
readonly inviterRoles?: ReadonlyArray<string>;
|
|
974
|
+
/** Role ordering, most privileged FIRST. Feeds the default
|
|
975
|
+
* {@link InvitationsConfig.canGrantRole}. Default
|
|
976
|
+
* `['owner','admin','member','viewer']`. */
|
|
977
|
+
readonly roleRank?: ReadonlyArray<string>;
|
|
978
|
+
/**
|
|
979
|
+
* Replace the whole "may this inviter hand out this role" rule.
|
|
980
|
+
*
|
|
981
|
+
* The default ranks by {@link InvitationsConfig.roleRank} and permits an
|
|
982
|
+
* inviter to grant their own role or any role BELOW it; a requested role
|
|
983
|
+
* absent from the rank list may be granted only by the top-ranked role, and
|
|
984
|
+
* an inviter whose own role is absent may grant nothing. Supply your own for
|
|
985
|
+
* any model that is not a line — a matrix, a per-tenant plan, a capability
|
|
986
|
+
* set. Whatever you write, keep it a refusal by default: this function is
|
|
987
|
+
* the only thing between an admin and self-promotion by invitation.
|
|
988
|
+
*/
|
|
989
|
+
readonly canGrantRole?: (ctx: GrantRoleContext) => boolean;
|
|
990
|
+
}
|
|
991
|
+
|
|
369
992
|
/** Verify a token's own HMAC (signature integrity), independent of the
|
|
370
993
|
* cookie/header match. */
|
|
371
994
|
export declare const isCsrfTokenWellFormed: (token: string, secret: string) => boolean;
|
|
372
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Read the verification mark off a session's Subject.
|
|
998
|
+
*
|
|
999
|
+
* `true` — proven.
|
|
1000
|
+
* `false` — NOT proven, and the session says so.
|
|
1001
|
+
* `null` — this session carries no mark at all.
|
|
1002
|
+
*
|
|
1003
|
+
* **`null` is not `false`, and conflating them is the failure this tri-state
|
|
1004
|
+
* exists to prevent.** A session minted while the policy was `'off'` — or by
|
|
1005
|
+
* an app that never enabled it — has nothing to say about verification. App
|
|
1006
|
+
* code that treats "no mark" as "unverified" would refuse every one of those
|
|
1007
|
+
* callers the moment the policy is switched on, which is the same outage the
|
|
1008
|
+
* `'off'` default avoids, re-created one layer up. Gate on `=== false`.
|
|
1009
|
+
*/
|
|
1010
|
+
export declare const isEmailVerified: (subject: Subject) => boolean | null;
|
|
1011
|
+
|
|
1012
|
+
/** Is this session an impersonated one? */
|
|
1013
|
+
export declare const isImpersonated: (subject: Subject) => boolean;
|
|
1014
|
+
|
|
373
1015
|
/**
|
|
374
1016
|
* Issue a fresh CSRF token: `<randomB64url>.<hmacB64url>`. Set this as a
|
|
375
1017
|
* readable (non-HttpOnly) cookie AND hand it to the SPA so it can echo
|
|
@@ -389,8 +1031,14 @@ export declare interface IssuedSession {
|
|
|
389
1031
|
* header in one call. Apps just attach the header to their
|
|
390
1032
|
* response. Pass a `SessionSecrets` set to sign with the current
|
|
391
1033
|
* rotation key (its `kid` is stamped into the payload).
|
|
1034
|
+
*
|
|
1035
|
+
* The subject is a `Subject | SubjectIdentity` at the type level and an
|
|
1036
|
+
* IDENTITY on the wire: a session cookie carries no `scopes`, and
|
|
1037
|
+
* `signSession` throws rather than dropping them silently. Authority for
|
|
1038
|
+
* this session is resolved per request by `auth.resolveScopes`, so a role
|
|
1039
|
+
* removed after sign-in takes effect on this same session.
|
|
392
1040
|
*/
|
|
393
|
-
export declare const issueSession: (subject: Subject, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
|
|
1041
|
+
export declare const issueSession: (subject: Subject | SubjectIdentity, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
|
|
394
1042
|
|
|
395
1043
|
export declare interface IssueSessionOptions {
|
|
396
1044
|
readonly ttlSeconds?: number;
|
|
@@ -399,20 +1047,60 @@ export declare interface IssueSessionOptions {
|
|
|
399
1047
|
}
|
|
400
1048
|
|
|
401
1049
|
/**
|
|
402
|
-
* Issue a real session for a
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
1050
|
+
* Issue a real session for a user: load memberships, write the `sessions` row
|
|
1051
|
+
* (so device-list + revocation cover it), and mint the signed cookie.
|
|
1052
|
+
*
|
|
1053
|
+
* **The ONE path that mints a session.** Password sign-in (post-MFA), sign-up,
|
|
1054
|
+
* magic-link, MFA verify, passkey assertion and impersonation all come through
|
|
1055
|
+
* here, so rotation, revocation, the session row and every metadata mark apply
|
|
1056
|
+
* uniformly. Sign-up and magic-link used to carry their own inline copies of
|
|
1057
|
+
* this body — which is exactly how a mark added in one place ends up missing
|
|
1058
|
+
* on two of five login paths, discoverable only by logging in the right way.
|
|
407
1059
|
*/
|
|
408
|
-
export declare const issueUserSession: (user: UserRecord, store: UserStore, config: AuthConfig, meta?: {
|
|
409
|
-
readonly ipAddress?: string | null;
|
|
410
|
-
readonly userAgent?: string | null;
|
|
411
|
-
}) => Effect.Effect<{
|
|
1060
|
+
export declare const issueUserSession: (user: UserRecord, store: UserStore, config: AuthConfig, meta?: IssueUserSessionOptions) => Effect.Effect<{
|
|
412
1061
|
readonly setCookie: string;
|
|
413
1062
|
readonly subject: ReturnType<typeof subjectFromUser>;
|
|
414
1063
|
}>;
|
|
415
1064
|
|
|
1065
|
+
/** What an `issueUserSession` caller may vary. */
|
|
1066
|
+
export declare interface IssueUserSessionOptions {
|
|
1067
|
+
readonly ipAddress?: string | null;
|
|
1068
|
+
readonly userAgent?: string | null;
|
|
1069
|
+
/**
|
|
1070
|
+
* Pre-allocated session-row id. Impersonation supplies one so the grant row
|
|
1071
|
+
* can be written BEFORE the session exists — a session with no record of who
|
|
1072
|
+
* is behind it must never be reachable, not even for a moment.
|
|
1073
|
+
*/
|
|
1074
|
+
readonly sessionId?: string;
|
|
1075
|
+
/** Cookie + session-row lifetime. Defaults to the 7-day session TTL;
|
|
1076
|
+
* impersonation passes its (much shorter) grant duration. */
|
|
1077
|
+
readonly ttlSeconds?: number;
|
|
1078
|
+
/**
|
|
1079
|
+
* `'insert'` (default) writes a fresh `sessions` row. `'existing'` mints a
|
|
1080
|
+
* cookie for a row that is ALREADY there and leaves it untouched.
|
|
1081
|
+
*
|
|
1082
|
+
* `'existing'` is the impersonation stop path putting the actor back into
|
|
1083
|
+
* their own, never-revoked session. Inserting there would duplicate the row
|
|
1084
|
+
* (the memory store appends; a keyed lookup then answers with whichever came
|
|
1085
|
+
* first), and touching it would silently extend the actor's own login by a
|
|
1086
|
+
* full lifetime every time they used the support tool.
|
|
1087
|
+
*/
|
|
1088
|
+
readonly sessionRow?: 'insert' | 'existing';
|
|
1089
|
+
/** Extra keys merged onto the Subject's metadata slot — the impersonation
|
|
1090
|
+
* mark. `sessionId` and the verification flag are set by this function and
|
|
1091
|
+
* win over anything here. */
|
|
1092
|
+
readonly metadata?: Record<string, unknown>;
|
|
1093
|
+
/**
|
|
1094
|
+
* Clear the brute-force counter for this email. Default TRUE, because a
|
|
1095
|
+
* completed login is proof the password is known.
|
|
1096
|
+
*
|
|
1097
|
+
* IMPERSONATION PASSES FALSE. Nobody authenticated as the target, so
|
|
1098
|
+
* clearing their lockout would let a support action undo brute-force
|
|
1099
|
+
* protection on the very account someone is hammering.
|
|
1100
|
+
*/
|
|
1101
|
+
readonly clearLoginFailures?: boolean;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
416
1104
|
export { KeyedSecret }
|
|
417
1105
|
|
|
418
1106
|
declare interface ListSessionsInput {
|
|
@@ -508,7 +1196,12 @@ export declare const memoryUserStore: (seed?: ReadonlyArray<UserRecord>) => User
|
|
|
508
1196
|
|
|
509
1197
|
/** Lifetime of the short-lived MFA pending token (the second-factor
|
|
510
1198
|
* challenge). Long enough for the user to fetch a TOTP code, short enough
|
|
511
|
-
* that a leaked challenge is near-useless.
|
|
1199
|
+
* that a leaked challenge is near-useless.
|
|
1200
|
+
*
|
|
1201
|
+
* Lives HERE rather than beside the MFA handler that mints it, because the
|
|
1202
|
+
* default-TTL table below has to be exhaustive over `TokenPurpose` and a
|
|
1203
|
+
* constant imported from `handlers.ts` would be a cycle. `handlers.ts`
|
|
1204
|
+
* re-exports it, so the public spelling is unchanged. */
|
|
512
1205
|
export declare const MFA_PENDING_TTL_S: number;
|
|
513
1206
|
|
|
514
1207
|
/** MFA (TOTP) enrolment config. */
|
|
@@ -564,6 +1257,19 @@ export declare interface MintedToken {
|
|
|
564
1257
|
readonly purpose: TokenPurpose;
|
|
565
1258
|
}
|
|
566
1259
|
|
|
1260
|
+
/**
|
|
1261
|
+
* Mint a fresh single-use secret with NO purpose attached — 32 CSPRNG bytes,
|
|
1262
|
+
* its SHA-256, and an expiry.
|
|
1263
|
+
*
|
|
1264
|
+
* Exists for the credentials that are single-use, hashed and expiring but do
|
|
1265
|
+
* NOT live in `authTokens`: a tenant invitation carries its own row (with an
|
|
1266
|
+
* address, a tenant and a role), so it has no `TokenPurpose` to name. Reaching
|
|
1267
|
+
* for `mintToken('magic-link', …)` just to obtain the bytes would file an
|
|
1268
|
+
* invitation under a purpose it does not have, in a type everything else reads
|
|
1269
|
+
* as authoritative.
|
|
1270
|
+
*/
|
|
1271
|
+
export declare const mintSecret: (ttlSeconds: number) => Omit<MintedToken, "purpose">;
|
|
1272
|
+
|
|
567
1273
|
/**
|
|
568
1274
|
* Mint a fresh single-use token. The caller persists `{ tokenHash,
|
|
569
1275
|
* userId, purpose, expiresAt }` via `UserStore.insertToken` and emails
|
|
@@ -596,7 +1302,8 @@ declare interface ParsedHash {
|
|
|
596
1302
|
}
|
|
597
1303
|
|
|
598
1304
|
/** Parse the scrypt parameters out of a stored hash. Returns null when the
|
|
599
|
-
* string isn't a well-formed `scrypt$N$r$p$salt$derived
|
|
1305
|
+
* string isn't a well-formed `scrypt$N$r$p$salt$derived`, or when its
|
|
1306
|
+
* parameters exceed the derivation ceiling. */
|
|
600
1307
|
export declare const parseScryptParams: (stored: string) => ParsedHash | null;
|
|
601
1308
|
|
|
602
1309
|
declare interface PasskeyAssertOptionsInput {
|
|
@@ -692,15 +1399,16 @@ export declare const postgresUserStore: (sql: SqlClient) => UserStore;
|
|
|
692
1399
|
|
|
693
1400
|
/**
|
|
694
1401
|
* Read + verify the session cookie from a Cookie header string.
|
|
695
|
-
* Returns the decoded Subject
|
|
696
|
-
*
|
|
697
|
-
*
|
|
1402
|
+
* Returns the decoded IDENTITY (a Subject with no `scopes` — authority is
|
|
1403
|
+
* resolved per request, never carried in the cookie) or null when the cookie
|
|
1404
|
+
* is missing, malformed, tampered, expired, or minted under a superseded
|
|
1405
|
+
* payload version. Never throws — callers branch on the boolean.
|
|
698
1406
|
*
|
|
699
1407
|
* Verification is keyed: the given secret is widened via
|
|
700
1408
|
* `sessionSecretsOf`, so a cookie signed with the previous rotation
|
|
701
1409
|
* key keeps verifying while `VOLTRO_SESSION_SECRET_PREVIOUS` is set.
|
|
702
1410
|
*/
|
|
703
|
-
export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) =>
|
|
1411
|
+
export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => SubjectIdentity | null;
|
|
704
1412
|
|
|
705
1413
|
/**
|
|
706
1414
|
* Keyed read: the full `VerifyResult` — subject + the `kid` that
|
|
@@ -710,9 +1418,18 @@ export declare const readSession: (cookieHeader: string | undefined, secret: Ses
|
|
|
710
1418
|
*/
|
|
711
1419
|
export declare const readSessionKeyed: (cookieHeader: string | undefined, secret: SessionSecretInput, options?: VerifyOptions) => VerifyResult | null;
|
|
712
1420
|
|
|
713
|
-
/**
|
|
714
|
-
*
|
|
715
|
-
|
|
1421
|
+
/**
|
|
1422
|
+
* Injected rebinder — the app passes `bindConnectionCredential` from
|
|
1423
|
+
* `@voltro/runtime` so the plugin stays runtime-agnostic.
|
|
1424
|
+
*
|
|
1425
|
+
* It takes the CREDENTIAL this handler just minted, not the Subject it built.
|
|
1426
|
+
* The Subject form was a fast path around the entire auth chain: the middleware
|
|
1427
|
+
* returned the stored value directly, so the connection never re-ran the
|
|
1428
|
+
* session-revocation check, `resolveScopes` or the scope cache again. Handing
|
|
1429
|
+
* the cookie over instead means the connection presents the same credential a
|
|
1430
|
+
* reconnecting browser would, and is judged the same way.
|
|
1431
|
+
*/
|
|
1432
|
+
export declare type RebindConnection = (clientId: number, credential: ConnectionCredential) => void;
|
|
716
1433
|
|
|
717
1434
|
/** A hashed, single-use MFA recovery (backup) code. Minted alongside
|
|
718
1435
|
* enrolment; accepted at sign-in as an alternative to a TOTP code when
|
|
@@ -790,11 +1507,21 @@ export declare interface SendEmailInput {
|
|
|
790
1507
|
readonly html: string;
|
|
791
1508
|
readonly text: string;
|
|
792
1509
|
/** Discriminates the flow so a custom sender can branch. */
|
|
793
|
-
readonly kind: 'magic-link' | 'password-reset';
|
|
1510
|
+
readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation';
|
|
794
1511
|
/** The action URL embedded in the email (also present inside `html`). */
|
|
795
1512
|
readonly actionUrl: string;
|
|
796
1513
|
}
|
|
797
1514
|
|
|
1515
|
+
/**
|
|
1516
|
+
* Mint + send a verification link for `user`. Shared by the request handler
|
|
1517
|
+
* and by sign-up, so the cooldown and the URL shape have ONE implementation.
|
|
1518
|
+
*
|
|
1519
|
+
* Returns whether an email was actually dispatched — `false` when the cooldown
|
|
1520
|
+
* suppressed it or no transport is wired. The caller must NOT surface that
|
|
1521
|
+
* distinction to an unauthenticated client (see `handleEmailVerifyRequest`).
|
|
1522
|
+
*/
|
|
1523
|
+
export declare const sendEmailVerification: (user: UserRecord, store: UserStore, deps: EmailVerificationDeps) => Effect.Effect<boolean>;
|
|
1524
|
+
|
|
798
1525
|
export declare const SESSION_COOKIE_NAME: "voltro:session";
|
|
799
1526
|
|
|
800
1527
|
/** Read the server-side session id carried on a Subject (stamped by the
|
|
@@ -970,7 +1697,7 @@ export declare interface SwitchTenantInput {
|
|
|
970
1697
|
readonly metadata?: Record<string, unknown>;
|
|
971
1698
|
}
|
|
972
1699
|
|
|
973
|
-
export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
|
|
1700
|
+
export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify';
|
|
974
1701
|
|
|
975
1702
|
/** A single-use, hashed, expiring token (magic-link / password-reset). */
|
|
976
1703
|
export declare interface TokenRecord {
|
|
@@ -1001,6 +1728,16 @@ declare const UserAlreadyExistsError_base: new <A extends Record<string, any> =
|
|
|
1001
1728
|
readonly _tag: "UserAlreadyExistsError";
|
|
1002
1729
|
} & Readonly<A>;
|
|
1003
1730
|
|
|
1731
|
+
/**
|
|
1732
|
+
* Is this user's address proven, under `config`?
|
|
1733
|
+
*
|
|
1734
|
+
* `exemptAccountsCreatedBefore` is applied HERE rather than at the guard, so
|
|
1735
|
+
* the exemption and the session mark cannot disagree — an exempt user must
|
|
1736
|
+
* read as verified to the client too, or the app shows a "confirm your email"
|
|
1737
|
+
* banner to someone the login already excused.
|
|
1738
|
+
*/
|
|
1739
|
+
export declare const userEmailVerified: (user: UserRecord, config: EmailVerificationConfig | undefined) => boolean;
|
|
1740
|
+
|
|
1004
1741
|
export declare class UserNotFoundError extends UserNotFoundError_base<{
|
|
1005
1742
|
readonly userId: string;
|
|
1006
1743
|
}> {
|
|
@@ -1039,6 +1776,18 @@ export declare interface UserRecord {
|
|
|
1039
1776
|
* verify). Distinct from the secret being set — a half-completed
|
|
1040
1777
|
* enrolment leaves `mfaSecret` populated but this null. */
|
|
1041
1778
|
readonly mfaEnrolledAt?: Date | null;
|
|
1779
|
+
/**
|
|
1780
|
+
* When this address was PROVEN to reach its owner — by redeeming a
|
|
1781
|
+
* verification link, a magic link, or a password-reset link. `null` /
|
|
1782
|
+
* absent = unproven.
|
|
1783
|
+
*
|
|
1784
|
+
* A store whose backing table predates the `emailVerifiedAt` column simply
|
|
1785
|
+
* leaves it undefined, which reads as unverified — and that is why
|
|
1786
|
+
* `emailVerification.policy` defaults to `'off'`: under any stricter
|
|
1787
|
+
* default, "the column is not there yet" and "this user never confirmed"
|
|
1788
|
+
* would both refuse the login.
|
|
1789
|
+
*/
|
|
1790
|
+
readonly emailVerifiedAt?: Date | null;
|
|
1042
1791
|
/** Optional lifecycle flag read by post-authentication subject guards.
|
|
1043
1792
|
* Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin-
|
|
1044
1793
|
* deactivation`'s `deactivationGuard()` vetoes login when it's non-null.
|
|
@@ -1067,6 +1816,15 @@ export declare interface UserStore {
|
|
|
1067
1816
|
/** Wipe the secret + enrolledAt. Used by /account/security's
|
|
1068
1817
|
* "remove MFA" action. */
|
|
1069
1818
|
readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
1819
|
+
/**
|
|
1820
|
+
* Stamp `emailVerifiedAt` — the address has been proven.
|
|
1821
|
+
*
|
|
1822
|
+
* IDEMPOTENT and non-regressing: a user already verified keeps their
|
|
1823
|
+
* ORIGINAL timestamp. "When was this address first proven" is the question
|
|
1824
|
+
* the column answers, and re-stamping it on every later magic-link login
|
|
1825
|
+
* would quietly turn it into "when did they last click something".
|
|
1826
|
+
*/
|
|
1827
|
+
readonly markEmailVerified: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
1070
1828
|
/** Every tenant the user belongs to. */
|
|
1071
1829
|
readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
|
|
1072
1830
|
/** Idempotent grant: inserts the [userId, tenantId] membership or
|
|
@@ -1104,6 +1862,52 @@ export declare interface UserStore {
|
|
|
1104
1862
|
* already consumed — and marks it consumed in the same step. Returns
|
|
1105
1863
|
* null otherwise (single-use guard). */
|
|
1106
1864
|
readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
|
|
1865
|
+
/**
|
|
1866
|
+
* The most recently CREATED token of this purpose for the user, consumed or
|
|
1867
|
+
* not — or null when there is none.
|
|
1868
|
+
*
|
|
1869
|
+
* Exists for the resend cooldown, which is the only bound on an endpoint
|
|
1870
|
+
* that sends mail to an address supplied by an unauthenticated caller. Note
|
|
1871
|
+
* it deliberately does NOT filter on `consumedAt`: the cost being rate-
|
|
1872
|
+
* limited is the EMAIL, and a token that was minted and then redeemed cost
|
|
1873
|
+
* exactly as much to send as one still pending.
|
|
1874
|
+
*/
|
|
1875
|
+
readonly latestToken: (userId: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
|
|
1876
|
+
/** Persist an invitation (token already hashed). */
|
|
1877
|
+
readonly insertInvitation: (invitation: Omit<InvitationRecord, 'acceptedAt' | 'acceptedByUserId' | 'revokedAt' | 'revokedByUserId' | 'createdAt'>) => Effect.Effect<InvitationRecord>;
|
|
1878
|
+
/** Look an invitation up by token hash WITHOUT redeeming it — the read the
|
|
1879
|
+
* "what am I being invited to" preview needs. Returns the row whatever its
|
|
1880
|
+
* state; the caller decides what an expired or revoked one means. */
|
|
1881
|
+
readonly findInvitationByHash: (tokenHash: string) => Effect.Effect<InvitationRecord | null>;
|
|
1882
|
+
/**
|
|
1883
|
+
* Atomically redeem an invitation: returns the row only when it exists, is
|
|
1884
|
+
* unaccepted, unrevoked and unexpired — and stamps `acceptedAt` +
|
|
1885
|
+
* `acceptedByUserId` in the same step. Null otherwise.
|
|
1886
|
+
*
|
|
1887
|
+
* The single-use guard, and it has to be ONE statement: two clicks on the
|
|
1888
|
+
* same emailed link race, and a read-then-write would let both through and
|
|
1889
|
+
* write the membership twice.
|
|
1890
|
+
*/
|
|
1891
|
+
readonly acceptInvitation: (tokenHash: string, acceptedByUserId: string, now: Date) => Effect.Effect<InvitationRecord | null>;
|
|
1892
|
+
/** Withdraw a PENDING invitation, scoped to its tenant so an admin of one
|
|
1893
|
+
* tenant cannot revoke another's by id. Returns whether a row changed. */
|
|
1894
|
+
readonly revokeInvitation: (invitationId: string, tenantId: string, revokedByUserId: string, now: Date) => Effect.Effect<boolean>;
|
|
1895
|
+
/** Every invitation for a tenant, newest first. The admin list. */
|
|
1896
|
+
readonly listInvitations: (tenantId: string) => Effect.Effect<ReadonlyArray<InvitationRecord>>;
|
|
1897
|
+
/** How many invitations for this tenant are still redeemable — the bound
|
|
1898
|
+
* `maxPending` enforces. */
|
|
1899
|
+
readonly countPendingInvitations: (tenantId: string, now: Date) => Effect.Effect<number>;
|
|
1900
|
+
/** Revoke every pending invitation for (tenant, email). Called before
|
|
1901
|
+
* issuing a fresh one so re-inviting supersedes rather than accumulates —
|
|
1902
|
+
* otherwise every resend leaves a live token behind. Returns the count. */
|
|
1903
|
+
readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
|
|
1904
|
+
/** Record an impersonation grant when the impersonated session is minted. */
|
|
1905
|
+
readonly insertImpersonationGrant: (grant: Omit<ImpersonationGrantRecord, 'startedAt' | 'endedAt' | 'endedReason'>) => Effect.Effect<ImpersonationGrantRecord>;
|
|
1906
|
+
/** The grant for an impersonated session, or null. The stop path's lookup. */
|
|
1907
|
+
readonly findImpersonationGrantBySession: (sessionId: string) => Effect.Effect<ImpersonationGrantRecord | null>;
|
|
1908
|
+
/** Close a grant. Only an OPEN grant is closed (`endedAt IS NULL`), so a
|
|
1909
|
+
* double stop is a no-op rather than a rewritten end time. */
|
|
1910
|
+
readonly endImpersonationGrant: (sessionId: string, endedReason: string, now: Date) => Effect.Effect<boolean>;
|
|
1107
1911
|
/** Replace the user's recovery codes with a fresh (already hashed) set.
|
|
1108
1912
|
* Called at enrolment / regeneration — wipes any prior codes so the
|
|
1109
1913
|
* displayed set is the only valid one. */
|