@voltro/plugin-auth 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/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +347 -0
- package/dist/csrf.d.ts +23 -0
- package/dist/csrf.js +22 -0
- package/dist/index.d.ts +1114 -0
- package/dist/index.js +262 -0
- package/dist/password.d.ts +78 -0
- package/dist/password.js +66 -0
- package/dist/plugin-CRt-kdEA.js +801 -0
- package/dist/plugin.d.ts +351 -0
- package/dist/plugin.js +4 -0
- package/dist/schema.d.ts +72 -0
- package/dist/schema.js +66 -0
- package/dist/session.d.ts +86 -0
- package/dist/session.js +38 -0
- package/dist/strategy-CQ2YCIcr.js +236 -0
- package/dist/strategy.d.ts +233 -0
- package/dist/strategy.js +3 -0
- package/dist/tokens.d.ts +31 -0
- package/dist/tokens.js +13 -0
- package/dist/web.d.ts +69 -0
- package/dist/web.js +90 -0
- package/dist/webauthn.d.ts +75 -0
- package/dist/webauthn.js +318 -0
- package/package.json +96 -0
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { AuthStrategy } from '@voltro/protocol';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { SessionSecrets } from '@voltro/protocol/session';
|
|
4
|
+
import { Subject } from '@voltro/protocol';
|
|
5
|
+
import { VoidIfEmpty } from 'effect/Types';
|
|
6
|
+
import { VoltroPlugin } from '@voltro/protocol';
|
|
7
|
+
import { YieldableError } from 'effect/Cause';
|
|
8
|
+
|
|
9
|
+
declare interface AuthConfig {
|
|
10
|
+
readonly secret: string;
|
|
11
|
+
/** Explicit keyed secret set for zero-downtime rotation. When absent,
|
|
12
|
+
* the set is derived from `secret` (current) + the
|
|
13
|
+
* `VOLTRO_SESSION_SECRET_PREVIOUS` / `VOLTRO_SESSION_KID*` env vars —
|
|
14
|
+
* so env-var rotation works without setting this. Cookies are always
|
|
15
|
+
* SIGNED with the current key; verification also accepts `previous`. */
|
|
16
|
+
readonly secrets?: SessionSecrets;
|
|
17
|
+
readonly defaultTenantId: string;
|
|
18
|
+
readonly cookieDomain?: string;
|
|
19
|
+
readonly cookieSecure?: boolean;
|
|
20
|
+
/** Where to redirect on successful sign-in / sign-up. Default: '/'. */
|
|
21
|
+
readonly successRedirect?: string;
|
|
22
|
+
/** Injected email transport for magic-link + password-reset. When
|
|
23
|
+
* absent, those handlers still mint + persist the token but cannot
|
|
24
|
+
* deliver it (they return 202 so existence isn't leaked). The
|
|
25
|
+
* documented default forwards to `@voltro/plugin-mail` via
|
|
26
|
+
* `mailSender(mailService)`. */
|
|
27
|
+
readonly sendEmail?: SendEmail;
|
|
28
|
+
/** Absolute base URL used to build the links inside emails, e.g.
|
|
29
|
+
* `https://app.example.com`. Defaults to '' (relative links). */
|
|
30
|
+
readonly appBaseUrl?: string;
|
|
31
|
+
/** Post-authentication subject guards. After a login path resolves the
|
|
32
|
+
* authenticated user (credentials verified) but BEFORE a session is
|
|
33
|
+
* issued, every guard runs against that `UserRecord`; the first to veto
|
|
34
|
+
* (`{ ok: false }`) aborts the login with a 403 carrying its `code` and
|
|
35
|
+
* NO session cookie. General-purpose: `@voltro/plugin-deactivation`'s
|
|
36
|
+
* `deactivationGuard()` is the canonical one ("account deactivated"),
|
|
37
|
+
* but any concern (unverified email, suspended tenant, …) can hook here.
|
|
38
|
+
* Absent / empty ⇒ every authenticated user proceeds unchanged. */
|
|
39
|
+
readonly subjectGuards?: ReadonlyArray<SubjectGuard>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The auth plugin instance also carries a pre-wired session
|
|
44
|
+
* `AuthStrategy` (keyed verify + the SAME revocation cache the HTTP
|
|
45
|
+
* routes use) so the framework's serve pipeline can slot it into the
|
|
46
|
+
* per-app auth chain — the rpc/WS path then rejects revoked sessions
|
|
47
|
+
* exactly like the `/auth/*` routes. Read it via
|
|
48
|
+
* `getAuthSessionStrategy`.
|
|
49
|
+
*/
|
|
50
|
+
export declare interface AuthPlugin extends VoltroPlugin {
|
|
51
|
+
readonly auth: {
|
|
52
|
+
readonly sessionStrategy: AuthStrategy;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export declare const authRoutesPlugin: (options: AuthRoutesPluginOptions) => AuthPlugin;
|
|
57
|
+
|
|
58
|
+
export declare interface AuthRoutesPluginOptions extends AuthConfig {
|
|
59
|
+
/** The user store backing every handler. */
|
|
60
|
+
readonly store: UserStore;
|
|
61
|
+
/** Passkey config — when omitted, the four passkey routes return 501. */
|
|
62
|
+
readonly passkey?: PasskeyConfig;
|
|
63
|
+
/** Challenge store for the passkey ceremonies. Defaults to an in-memory
|
|
64
|
+
* single-node store. */
|
|
65
|
+
readonly challengeStore?: ChallengeStore;
|
|
66
|
+
/** MFA (TOTP) enrolment config. When set, the `/auth/mfa/enroll/*`
|
|
67
|
+
* routes are mounted so an authenticated user can enrol a second
|
|
68
|
+
* factor; `issuer` is the label shown in their authenticator app.
|
|
69
|
+
* Sign-in enforcement (the `/auth/mfa/verify` challenge) is ALWAYS
|
|
70
|
+
* active for enrolled users regardless of this — it only gates the
|
|
71
|
+
* enrolment routes. */
|
|
72
|
+
readonly mfa?: MfaConfig;
|
|
73
|
+
/** Rebind hook for switch-tenant on the live WS path — pass
|
|
74
|
+
* `bindConnectionSubject` from `@voltro/runtime`. */
|
|
75
|
+
readonly rebind?: RebindConnection;
|
|
76
|
+
/** Route prefix. Default `/auth`. */
|
|
77
|
+
readonly prefix?: string;
|
|
78
|
+
/** Tuning for the request-time session-revocation check (TTL cache
|
|
79
|
+
* window, default 30s; `now` is a testing seam). */
|
|
80
|
+
readonly sessionRevocation?: SessionRevocationOptions;
|
|
81
|
+
/** Disambiguates multiple instances of this plugin in one app. */
|
|
82
|
+
readonly name?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Single-use challenge storage. `key` is `<userId>:<ceremony>`. */
|
|
86
|
+
declare interface ChallengeStore {
|
|
87
|
+
readonly put: (key: string, challenge: string, ttlSeconds: number) => Effect.Effect<void>;
|
|
88
|
+
/** Read AND delete (single-use). Returns null when absent/expired. */
|
|
89
|
+
readonly take: (key: string) => Effect.Effect<string | null>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pull the pre-wired session strategy off a plugin list (the framework's
|
|
94
|
+
* serve pipeline calls this when composing the auth chain). Returns the
|
|
95
|
+
* FIRST auth plugin's strategy, or null when no auth plugin is present.
|
|
96
|
+
*/
|
|
97
|
+
export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugin>) => AuthStrategy | null;
|
|
98
|
+
|
|
99
|
+
/** One tenant a user belongs to. The active tenant lives on the Subject;
|
|
100
|
+
* the full set drives the switch-tenant menu + the switch-tenant guard. */
|
|
101
|
+
declare interface MembershipRecord {
|
|
102
|
+
readonly userId: string;
|
|
103
|
+
readonly tenantId: string;
|
|
104
|
+
readonly role: string;
|
|
105
|
+
readonly joinedAt: Date;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** MFA (TOTP) enrolment config. */
|
|
109
|
+
export declare interface MfaConfig {
|
|
110
|
+
/** Issuer label shown in the user's authenticator app (typically your
|
|
111
|
+
* product name, e.g. "Voltro Cloud"). */
|
|
112
|
+
readonly issuer: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
declare interface PasskeyConfig {
|
|
116
|
+
/** Relying-party id — the registrable domain (e.g. `example.com`). */
|
|
117
|
+
readonly rpId: string;
|
|
118
|
+
/** Human-readable RP name shown in the OS prompt. */
|
|
119
|
+
readonly rpName: string;
|
|
120
|
+
/** The exact origin ceremonies must run on (e.g. `https://app.example.com`). */
|
|
121
|
+
readonly origin: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A registered WebAuthn credential. */
|
|
125
|
+
declare interface PasskeyRecord {
|
|
126
|
+
readonly id: string;
|
|
127
|
+
readonly credentialId: string;
|
|
128
|
+
readonly userId: string;
|
|
129
|
+
readonly publicKey: string;
|
|
130
|
+
readonly counter: number;
|
|
131
|
+
readonly transports?: string | null;
|
|
132
|
+
readonly createdAt: Date;
|
|
133
|
+
readonly lastUsedAt: Date | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Injected rebinder — the app passes `bindConnectionSubject` from
|
|
137
|
+
* `@voltro/runtime` so the plugin stays runtime-agnostic. */
|
|
138
|
+
declare type RebindConnection = (clientId: number, subject: ReturnType<typeof subjectFromUser>) => void;
|
|
139
|
+
|
|
140
|
+
declare type SendEmail = (input: SendEmailInput) => Promise<void>;
|
|
141
|
+
|
|
142
|
+
/** The email hook the plugin calls for magic-link + password-reset. Inject
|
|
143
|
+
* it on the config. The documented default wiring forwards to
|
|
144
|
+
* `@voltro/plugin-mail`'s `MailService.send` (see `mailSender`). */
|
|
145
|
+
declare interface SendEmailInput {
|
|
146
|
+
readonly to: string;
|
|
147
|
+
readonly subject: string;
|
|
148
|
+
readonly html: string;
|
|
149
|
+
readonly text: string;
|
|
150
|
+
/** Discriminates the flow so a custom sender can branch. */
|
|
151
|
+
readonly kind: 'magic-link' | 'password-reset';
|
|
152
|
+
/** The action URL embedded in the email (also present inside `html`). */
|
|
153
|
+
readonly actionUrl: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A server-side session row — enumerated so apps can list active
|
|
157
|
+
* devices + revoke them. Written on sign-in; deleted on revoke. */
|
|
158
|
+
declare interface SessionRecord {
|
|
159
|
+
readonly id: string;
|
|
160
|
+
readonly userId: string;
|
|
161
|
+
readonly tenantId: string;
|
|
162
|
+
readonly expiresAt: Date;
|
|
163
|
+
readonly ipAddress?: string | null;
|
|
164
|
+
readonly userAgent?: string | null;
|
|
165
|
+
readonly createdAt: Date;
|
|
166
|
+
readonly lastSeenAt: Date;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
declare interface SessionRevocationOptions {
|
|
170
|
+
/** Cache window in milliseconds. A revocation performed elsewhere
|
|
171
|
+
* takes effect on this process within this window. Default 30s.
|
|
172
|
+
* `0` disables caching (every verify hits the store). */
|
|
173
|
+
readonly ttlMs?: number;
|
|
174
|
+
/** Upper bound on cached session ids. When exceeded, expired entries
|
|
175
|
+
* are swept; if still over, the oldest entries are dropped. Default
|
|
176
|
+
* 10 000. */
|
|
177
|
+
readonly maxEntries?: number;
|
|
178
|
+
/** Clock override (epoch milliseconds). Defaults to `Date.now`. A
|
|
179
|
+
* testing seam — lets suites cross the cache window without
|
|
180
|
+
* wall-clock sleeps. */
|
|
181
|
+
readonly now?: () => number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Convert a UserRecord into the protocol Subject the framework
|
|
186
|
+
* carries around per-request. Tenant id flows through; user type
|
|
187
|
+
* is locked to 'user'.
|
|
188
|
+
*
|
|
189
|
+
* The user's tenant memberships, when supplied, are carried in
|
|
190
|
+
* `metadata.memberships` (the protocol Subject's metadata slot — the
|
|
191
|
+
* framework never reads it, but app code + the switch-tenant menu do).
|
|
192
|
+
* Pass an explicit `tenantId` to make the ACTIVE tenant differ from the
|
|
193
|
+
* user's home tenant (post switch-tenant rebind).
|
|
194
|
+
*/
|
|
195
|
+
declare const subjectFromUser: (user: UserRecord, options?: {
|
|
196
|
+
readonly tenantId?: string;
|
|
197
|
+
readonly memberships?: ReadonlyArray<MembershipRecord>;
|
|
198
|
+
}) => Subject;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A post-authentication subject guard: given the authenticated user,
|
|
202
|
+
* decide whether the login may proceed. Runs after the credential check,
|
|
203
|
+
* before the session is issued. Effect-native so a guard can do IO (e.g.
|
|
204
|
+
* read a fresh flag) without a Promise bridge.
|
|
205
|
+
*/
|
|
206
|
+
declare type SubjectGuard = (user: UserRecord) => Effect.Effect<SubjectGuardVerdict>;
|
|
207
|
+
|
|
208
|
+
/** A guard's decision. Allow, or reject with a stable machine `code`
|
|
209
|
+
* (surfaced as the 403 body's `error`) plus a human `message`. */
|
|
210
|
+
declare type SubjectGuardVerdict = {
|
|
211
|
+
readonly ok: true;
|
|
212
|
+
} | {
|
|
213
|
+
readonly ok: false;
|
|
214
|
+
readonly code: string;
|
|
215
|
+
readonly message: string;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
|
|
219
|
+
|
|
220
|
+
/** A single-use, hashed, expiring token (magic-link / password-reset). */
|
|
221
|
+
declare interface TokenRecord {
|
|
222
|
+
readonly id: string;
|
|
223
|
+
readonly tokenHash: string;
|
|
224
|
+
readonly userId: string;
|
|
225
|
+
readonly purpose: TokenPurpose;
|
|
226
|
+
readonly expiresAt: Date;
|
|
227
|
+
readonly consumedAt: Date | null;
|
|
228
|
+
readonly createdAt: Date;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
declare class UserAlreadyExistsError extends UserAlreadyExistsError_base<{
|
|
232
|
+
readonly email: string;
|
|
233
|
+
}> {
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
declare const UserAlreadyExistsError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
|
|
237
|
+
readonly _tag: "UserAlreadyExistsError";
|
|
238
|
+
} & Readonly<A>;
|
|
239
|
+
|
|
240
|
+
declare class UserNotFoundError extends UserNotFoundError_base<{
|
|
241
|
+
readonly userId: string;
|
|
242
|
+
}> {
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
declare const UserNotFoundError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
|
|
246
|
+
readonly _tag: "UserNotFoundError";
|
|
247
|
+
} & Readonly<A>;
|
|
248
|
+
|
|
249
|
+
declare interface UserRecord {
|
|
250
|
+
readonly id: string;
|
|
251
|
+
readonly email: string;
|
|
252
|
+
readonly passwordHash: string;
|
|
253
|
+
readonly tenantId: string;
|
|
254
|
+
readonly createdAt: Date;
|
|
255
|
+
/** Base32-encoded TOTP secret. `null` = MFA not enrolled. Set by
|
|
256
|
+
* `setMfaSecret()` from the enrolment handler. Stored as plaintext. */
|
|
257
|
+
readonly mfaSecret?: string | null;
|
|
258
|
+
/** Timestamp the user completed TOTP enrolment (first successful
|
|
259
|
+
* verify). Distinct from the secret being set — a half-completed
|
|
260
|
+
* enrolment leaves `mfaSecret` populated but this null. */
|
|
261
|
+
readonly mfaEnrolledAt?: Date | null;
|
|
262
|
+
/** Optional lifecycle flag read by post-authentication subject guards.
|
|
263
|
+
* Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin-
|
|
264
|
+
* deactivation`'s `deactivationGuard()` vetoes login when it's non-null.
|
|
265
|
+
* `null` / absent means active. A store populates it from the app's
|
|
266
|
+
* `deactivatedAt` column (added by the `deactivation()` mixin) when that
|
|
267
|
+
* column exists; stores without the column simply leave it undefined and
|
|
268
|
+
* the guard treats the account as active. */
|
|
269
|
+
readonly deactivatedAt?: Date | null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
declare interface UserStore {
|
|
273
|
+
readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
|
|
274
|
+
readonly findById: (id: string) => Effect.Effect<UserRecord | null>;
|
|
275
|
+
readonly insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>;
|
|
276
|
+
/** Replace a user's stored password hash. Used by password-reset AND
|
|
277
|
+
* by rehash-on-verify (silently upgrading an under-cost hash on a
|
|
278
|
+
* successful sign-in). */
|
|
279
|
+
readonly updatePassword: (userId: string, newHash: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
280
|
+
/** Stash a freshly-generated TOTP secret on the user. Called by
|
|
281
|
+
* the MFA enrolment handler before the user verifies the first
|
|
282
|
+
* code. */
|
|
283
|
+
readonly setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
284
|
+
/** Mark MFA as fully enrolled (called after the first successful
|
|
285
|
+
* TOTP verify completes the enrolment ceremony). */
|
|
286
|
+
readonly markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
287
|
+
/** Wipe the secret + enrolledAt. Used by /account/security's
|
|
288
|
+
* "remove MFA" action. */
|
|
289
|
+
readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
290
|
+
/** Every tenant the user belongs to. */
|
|
291
|
+
readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
|
|
292
|
+
/** Idempotent grant: inserts the [userId, tenantId] membership or
|
|
293
|
+
* updates its role if it already exists. The DB-level composite
|
|
294
|
+
* UNIQUE on `[userId, tenantId]` (see `membershipsTable`) is the
|
|
295
|
+
* source of truth; this upsert converges to it. */
|
|
296
|
+
readonly addMembership: (membership: Omit<MembershipRecord, 'joinedAt'>) => Effect.Effect<MembershipRecord>;
|
|
297
|
+
/** Resolve the role for a [userId, tenantId] pair, or null when the
|
|
298
|
+
* user is NOT a member. The switch-tenant handler calls this to
|
|
299
|
+
* validate the requested tenant before rebinding the connection. */
|
|
300
|
+
readonly membershipRole: (userId: string, tenantId: string) => Effect.Effect<string | null>;
|
|
301
|
+
/** Active sessions for a user (most recent first). */
|
|
302
|
+
readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
|
|
303
|
+
/** Record a session on sign-in. */
|
|
304
|
+
readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
|
|
305
|
+
/** Look up one session row by id. The request-time revocation check
|
|
306
|
+
* (`makeSessionRevocationChecker`) calls this — a missing row means
|
|
307
|
+
* the session was revoked. */
|
|
308
|
+
readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
|
|
309
|
+
/** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
|
|
310
|
+
* when the sliding-window renewal re-issues the cookie, so the row
|
|
311
|
+
* outlives the renewed cookie and the revocation check keeps passing. */
|
|
312
|
+
readonly touchSession: (sessionId: string, expiresAt: Date) => Effect.Effect<void>;
|
|
313
|
+
/** Revoke a single session — only if it belongs to `userId` (so a
|
|
314
|
+
* caller can't revoke another user's session by id-guessing). Returns
|
|
315
|
+
* whether a row was removed. */
|
|
316
|
+
readonly revokeSession: (userId: string, sessionId: string) => Effect.Effect<boolean>;
|
|
317
|
+
/** Revoke every session for the user EXCEPT `keepSessionId` — the
|
|
318
|
+
* "sign out other devices" action. Returns the count removed. */
|
|
319
|
+
readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>;
|
|
320
|
+
/** Persist a single-use token (already hashed). */
|
|
321
|
+
readonly insertToken: (token: Omit<TokenRecord, 'consumedAt' | 'createdAt'>) => Effect.Effect<TokenRecord>;
|
|
322
|
+
/** Atomically redeem a token by its hash + purpose: returns the row
|
|
323
|
+
* only when it exists, matches the purpose, is unexpired, and was not
|
|
324
|
+
* already consumed — and marks it consumed in the same step. Returns
|
|
325
|
+
* null otherwise (single-use guard). */
|
|
326
|
+
readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
|
|
327
|
+
/** Replace the user's recovery codes with a fresh (already hashed) set.
|
|
328
|
+
* Called at enrolment / regeneration — wipes any prior codes so the
|
|
329
|
+
* displayed set is the only valid one. */
|
|
330
|
+
readonly replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray<string>) => Effect.Effect<void>;
|
|
331
|
+
/** Atomically redeem one recovery code by its hash: returns true only
|
|
332
|
+
* when an unconsumed row for `userId` matched — and marks it consumed
|
|
333
|
+
* in the same step (single-use). false otherwise. */
|
|
334
|
+
readonly consumeRecoveryCode: (userId: string, codeHash: string) => Effect.Effect<boolean>;
|
|
335
|
+
/** Count the user's remaining (unconsumed) recovery codes — surfaced so
|
|
336
|
+
* the UI can warn when the user is running low. */
|
|
337
|
+
readonly countRecoveryCodes: (userId: string) => Effect.Effect<number>;
|
|
338
|
+
readonly listPasskeys: (userId: string) => Effect.Effect<ReadonlyArray<PasskeyRecord>>;
|
|
339
|
+
readonly findPasskey: (credentialId: string) => Effect.Effect<PasskeyRecord | null>;
|
|
340
|
+
readonly insertPasskey: (passkey: Omit<PasskeyRecord, 'createdAt' | 'lastUsedAt'>) => Effect.Effect<PasskeyRecord>;
|
|
341
|
+
/** Atomically bump the stored signature counter, guarding against a
|
|
342
|
+
* cloned authenticator (WebAuthn §6.1.1): the update only applies when
|
|
343
|
+
* `newCounter` strictly exceeds the stored counter. Returns whether a
|
|
344
|
+
* row was advanced. `false` with an unchanged stored counter is the
|
|
345
|
+
* clone/replay signal the assertion handler rejects on — the check and
|
|
346
|
+
* the write are ONE atomic statement at the store, so two replicas
|
|
347
|
+
* racing the same counter can't both succeed. */
|
|
348
|
+
readonly advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect<boolean>;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export { }
|
package/dist/plugin.js
ADDED
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { TableLike } from '@voltro/database';
|
|
2
|
+
|
|
3
|
+
/** Tables the plugin contributes, as an array. */
|
|
4
|
+
export declare const authTables: readonly [TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Auth-token table. Single-use, hashed, expiring tokens for the
|
|
8
|
+
* magic-link sign-in + password-reset flows. We store only the SHA-256
|
|
9
|
+
* of the token (never the plaintext) so a DB leak can't be replayed.
|
|
10
|
+
* `purpose` discriminates the flow; `consumedAt` is set the first time
|
|
11
|
+
* a token is redeemed (single-use guard).
|
|
12
|
+
*/
|
|
13
|
+
export declare const authTokensTable: TableLike;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Memberships table. A user belongs to MANY tenants; each row is one
|
|
17
|
+
* membership with a `role`. The active tenant lives on the Subject, but
|
|
18
|
+
* the set of tenants a user MAY switch into is enumerated here.
|
|
19
|
+
*
|
|
20
|
+
* `[userId, tenantId]` is a composite UNIQUE — the DB enforces the
|
|
21
|
+
* one-membership-per-(user, tenant) invariant that `addMembership`
|
|
22
|
+
* upserts toward, and the unique constraint doubles as the BTREE index
|
|
23
|
+
* backing the membership lookup + the dedup probe (so no separate
|
|
24
|
+
* `.index([...])` on the same columns is needed).
|
|
25
|
+
*/
|
|
26
|
+
export declare const membershipsTable: TableLike;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Passkey ceremony challenges — the DataStore-backed `ChallengeStore`
|
|
30
|
+
* (`dataStoreChallengeStore`) writes here so a multi-replica deployment's
|
|
31
|
+
* register/assert ceremonies survive when `options` and `verify` land on
|
|
32
|
+
* different replicas (the in-memory store is single-node only). Rows are
|
|
33
|
+
* single-use + short-lived: `take` deletes on read, and `expiresAt` bounds
|
|
34
|
+
* a challenge abandoned before verify. `key` is `<userId>:<ceremony>`.
|
|
35
|
+
*/
|
|
36
|
+
export declare const passkeyChallengesTable: TableLike;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Passkey (WebAuthn) credentials. One row per registered authenticator.
|
|
40
|
+
* `publicKey` is the COSE public key (base64url). `counter` is the
|
|
41
|
+
* authenticator's signature counter — the assertion handler rejects any
|
|
42
|
+
* assertion whose counter does not strictly exceed the stored value
|
|
43
|
+
* (cloned-authenticator detection).
|
|
44
|
+
*/
|
|
45
|
+
export declare const passkeysTable: TableLike;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* MFA recovery (backup) codes. One row per code, stored HASHED (SHA-256,
|
|
49
|
+
* same as auth tokens — never the plaintext). Minted at enrolment, shown
|
|
50
|
+
* once, single-use: `consumedAt` is stamped the first time a code is
|
|
51
|
+
* redeemed at sign-in as the authenticator-loss fallback.
|
|
52
|
+
*/
|
|
53
|
+
export declare const recoveryCodesTable: TableLike;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sessions table. Active sessions enumerated server-side so apps can
|
|
57
|
+
* implement "sign out other devices" + server-side revocation.
|
|
58
|
+
* `handleSignIn` / `handleSignOut` do NOT write here (cookies are
|
|
59
|
+
* self-describing HMAC-signed); apps that need device management
|
|
60
|
+
* wire that up themselves.
|
|
61
|
+
*/
|
|
62
|
+
export declare const sessionsTable: TableLike;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Users table. `tenantId` is a plain text column (not a reference)
|
|
66
|
+
* so the table can be added to apps that haven't yet declared their
|
|
67
|
+
* own `tenants` table. Apps with a tenants store can layer a
|
|
68
|
+
* referential constraint on top via the DB schema or DSL extensions.
|
|
69
|
+
*/
|
|
70
|
+
export declare const usersTable: TableLike;
|
|
71
|
+
|
|
72
|
+
export { }
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { id as e, integer as t, table as n, text as r, timestamp as i } from "@voltro/database";
|
|
2
|
+
//#region src/schema/index.ts
|
|
3
|
+
var a = n("users", {
|
|
4
|
+
id: e(),
|
|
5
|
+
email: r().unique(),
|
|
6
|
+
passwordHash: r(),
|
|
7
|
+
tenantId: r(),
|
|
8
|
+
mfaSecret: r().nullable(),
|
|
9
|
+
mfaEnrolledAt: i().nullable(),
|
|
10
|
+
createdAt: i().default("now"),
|
|
11
|
+
updatedAt: i().default("now").onUpdate("now")
|
|
12
|
+
}).index(["tenantId"]), o = n("sessions", {
|
|
13
|
+
id: e(),
|
|
14
|
+
userId: r(),
|
|
15
|
+
tenantId: r(),
|
|
16
|
+
expiresAt: i(),
|
|
17
|
+
ipAddress: r().nullable(),
|
|
18
|
+
userAgent: r().nullable(),
|
|
19
|
+
createdAt: i().default("now"),
|
|
20
|
+
lastSeenAt: i().default("now").onUpdate("now")
|
|
21
|
+
}).index(["userId"]).index(["tenantId"]), s = n("memberships", {
|
|
22
|
+
id: e(),
|
|
23
|
+
userId: r(),
|
|
24
|
+
tenantId: r(),
|
|
25
|
+
role: r(),
|
|
26
|
+
joinedAt: i().default("now")
|
|
27
|
+
}).unique("byUserTenant", ["userId", "tenantId"]).index(["tenantId"]), c = n("authTokens", {
|
|
28
|
+
id: e(),
|
|
29
|
+
tokenHash: r().unique(),
|
|
30
|
+
userId: r(),
|
|
31
|
+
purpose: r(),
|
|
32
|
+
expiresAt: i(),
|
|
33
|
+
consumedAt: i().nullable(),
|
|
34
|
+
createdAt: i().default("now")
|
|
35
|
+
}).index(["userId"]).index(["purpose"]), l = n("passkeys", {
|
|
36
|
+
id: e(),
|
|
37
|
+
credentialId: r().unique(),
|
|
38
|
+
userId: r(),
|
|
39
|
+
publicKey: r(),
|
|
40
|
+
counter: t().default(0),
|
|
41
|
+
transports: r().nullable(),
|
|
42
|
+
createdAt: i().default("now"),
|
|
43
|
+
lastUsedAt: i().nullable()
|
|
44
|
+
}).index(["userId"]), u = n("recoveryCodes", {
|
|
45
|
+
id: e(),
|
|
46
|
+
userId: r(),
|
|
47
|
+
codeHash: r(),
|
|
48
|
+
consumedAt: i().nullable(),
|
|
49
|
+
createdAt: i().default("now")
|
|
50
|
+
}).index(["userId"]), d = n("passkeyChallenges", {
|
|
51
|
+
id: e(),
|
|
52
|
+
key: r().unique(),
|
|
53
|
+
challenge: r(),
|
|
54
|
+
expiresAt: i(),
|
|
55
|
+
createdAt: i().default("now")
|
|
56
|
+
}), f = [
|
|
57
|
+
a,
|
|
58
|
+
o,
|
|
59
|
+
s,
|
|
60
|
+
c,
|
|
61
|
+
l,
|
|
62
|
+
u,
|
|
63
|
+
d
|
|
64
|
+
];
|
|
65
|
+
//#endregion
|
|
66
|
+
export { f as authTables, c as authTokensTable, s as membershipsTable, d as passkeyChallengesTable, l as passkeysTable, u as recoveryCodesTable, o as sessionsTable, a as usersTable };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { KeyedSecret } from '@voltro/protocol/session';
|
|
2
|
+
import { SessionSecrets } from '@voltro/protocol/session';
|
|
3
|
+
import { Subject } from '@voltro/protocol';
|
|
4
|
+
import { VerifyOptions } from '@voltro/protocol/session';
|
|
5
|
+
import { VerifyResult } from '@voltro/protocol/session';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a `Set-Cookie` value that clears the session cookie. Send
|
|
9
|
+
* this on logout.
|
|
10
|
+
*/
|
|
11
|
+
export declare const clearSessionCookie: (options?: IssueSessionOptions) => string;
|
|
12
|
+
|
|
13
|
+
export declare interface IssuedSession {
|
|
14
|
+
/** The opaque cookie value — `<payload>.<sig>`. */
|
|
15
|
+
readonly value: string;
|
|
16
|
+
/** Ready-to-Set-Cookie header string with sane defaults. */
|
|
17
|
+
readonly setCookie: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mint a signed session AND build the corresponding `Set-Cookie`
|
|
22
|
+
* header in one call. Apps just attach the header to their
|
|
23
|
+
* response. Pass a `SessionSecrets` set to sign with the current
|
|
24
|
+
* rotation key (its `kid` is stamped into the payload).
|
|
25
|
+
*/
|
|
26
|
+
export declare const issueSession: (subject: Subject, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
|
|
27
|
+
|
|
28
|
+
export declare interface IssueSessionOptions {
|
|
29
|
+
readonly ttlSeconds?: number;
|
|
30
|
+
readonly domain?: string;
|
|
31
|
+
readonly secure?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { KeyedSecret }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read + verify the session cookie from a Cookie header string.
|
|
38
|
+
* Returns the decoded Subject or null when the cookie is missing,
|
|
39
|
+
* malformed, tampered, or expired. Never throws — callers branch
|
|
40
|
+
* on the boolean.
|
|
41
|
+
*
|
|
42
|
+
* Verification is keyed: the given secret is widened via
|
|
43
|
+
* `sessionSecretsOf`, so a cookie signed with the previous rotation
|
|
44
|
+
* key keeps verifying while `VOLTRO_SESSION_SECRET_PREVIOUS` is set.
|
|
45
|
+
*/
|
|
46
|
+
export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => Subject | null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Keyed read: the full `VerifyResult` — subject + the `kid` that
|
|
50
|
+
* verified + the sliding-window `renew` flag + `exp`/`iat`. The auth
|
|
51
|
+
* routes plugin re-issues the cookie when `renew` is set or when the
|
|
52
|
+
* value verified under the previous key.
|
|
53
|
+
*/
|
|
54
|
+
export declare const readSessionKeyed: (cookieHeader: string | undefined, secret: SessionSecretInput, options?: VerifyOptions) => VerifyResult | null;
|
|
55
|
+
|
|
56
|
+
export declare const resolveSessionSecret: () => string;
|
|
57
|
+
|
|
58
|
+
export declare const resolveSessionSecrets: () => SessionSecrets;
|
|
59
|
+
|
|
60
|
+
export declare const SESSION_COOKIE_NAME: "voltro:session";
|
|
61
|
+
|
|
62
|
+
/** Every session helper takes either a bare secret string (single-key)
|
|
63
|
+
* or the keyed `{ current, previous? }` rotation set. */
|
|
64
|
+
export declare type SessionSecretInput = string | SessionSecrets;
|
|
65
|
+
|
|
66
|
+
export { SessionSecrets }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalise a secret input into the keyed set VERIFICATION runs against.
|
|
70
|
+
*
|
|
71
|
+
* - A `SessionSecrets` set passes through unchanged.
|
|
72
|
+
* - A bare string becomes `current` and is widened with the env-driven
|
|
73
|
+
* `previous` key (`VOLTRO_SESSION_SECRET_PREVIOUS` +
|
|
74
|
+
* `VOLTRO_SESSION_KID_PREVIOUS`) so env-var rotation works without
|
|
75
|
+
* the app switching its config to the keyed shape. When the string
|
|
76
|
+
* IS the env secret (`VOLTRO_SESSION_SECRET`), it inherits the env
|
|
77
|
+
* `kid`; otherwise the default kid applies. The comparison is
|
|
78
|
+
* constant-time — secrets never go through `===`.
|
|
79
|
+
*/
|
|
80
|
+
export declare const sessionSecretsOf: (secret: SessionSecretInput) => SessionSecrets;
|
|
81
|
+
|
|
82
|
+
export { VerifyResult }
|
|
83
|
+
|
|
84
|
+
export declare const VOLTRO_DEV_SESSION_SECRET: "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD";
|
|
85
|
+
|
|
86
|
+
export { }
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DEFAULT_SESSION_KID as e, VOLTRO_DEV_SESSION_SECRET as t, buildSetCookie as n, readCookie as r, resolveSessionSecret as i, resolveSessionSecrets as a, signSession as o, timingSafeStringEqual as s, verifySessionKeyed as c } from "@voltro/protocol/session";
|
|
2
|
+
//#region src/session.ts
|
|
3
|
+
var l = "voltro:session", u = 3600 * 24 * 7, d = t, f = i, p = a, m = (e) => typeof e == "string" ? e : e.current, h = (t) => {
|
|
4
|
+
if (typeof t != "string") return t;
|
|
5
|
+
let n = a(), r = {
|
|
6
|
+
kid: s(n.current.secret, t) ? n.current.kid : e,
|
|
7
|
+
secret: t
|
|
8
|
+
};
|
|
9
|
+
return n.previous ? {
|
|
10
|
+
current: r,
|
|
11
|
+
previous: n.previous
|
|
12
|
+
} : { current: r };
|
|
13
|
+
}, g = (e, t, r = {}) => {
|
|
14
|
+
let i = r.ttlSeconds ?? u, a = o(e, m(t), { ttlSeconds: i });
|
|
15
|
+
return {
|
|
16
|
+
value: a,
|
|
17
|
+
setCookie: n(l, a, {
|
|
18
|
+
maxAgeSeconds: i,
|
|
19
|
+
httpOnly: !0,
|
|
20
|
+
sameSite: "lax",
|
|
21
|
+
secure: r.secure ?? !0,
|
|
22
|
+
...r.domain ? { domain: r.domain } : {},
|
|
23
|
+
path: "/"
|
|
24
|
+
})
|
|
25
|
+
};
|
|
26
|
+
}, _ = (e, t) => v(e, t)?.subject ?? null, v = (e, t, n = {}) => {
|
|
27
|
+
let i = r(e, l);
|
|
28
|
+
return i ? c(i, h(t), n) : null;
|
|
29
|
+
}, y = (e = {}) => n(l, "", {
|
|
30
|
+
maxAgeSeconds: 0,
|
|
31
|
+
httpOnly: !0,
|
|
32
|
+
sameSite: "lax",
|
|
33
|
+
secure: e.secure ?? !0,
|
|
34
|
+
...e.domain ? { domain: e.domain } : {},
|
|
35
|
+
path: "/"
|
|
36
|
+
});
|
|
37
|
+
//#endregion
|
|
38
|
+
export { l as SESSION_COOKIE_NAME, d as VOLTRO_DEV_SESSION_SECRET, y as clearSessionCookie, g as issueSession, _ as readSession, v as readSessionKeyed, f as resolveSessionSecret, p as resolveSessionSecrets, h as sessionSecretsOf };
|