@voltro/plugin-auth 0.86.0 → 0.88.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +5484 -0
  2. package/README.md +83 -0
  3. package/THIRD-PARTY-NOTICES.md +3 -227
  4. package/dist/admission.js +1 -2
  5. package/dist/appUserStore.js +1 -2
  6. package/dist/authExtensions.js +8 -0
  7. package/dist/csrf.js +1 -2
  8. package/dist/emailVerification.js +14 -13
  9. package/dist/guest.d.ts +99 -0
  10. package/dist/guest.js +3 -0
  11. package/dist/guestClient.js +236 -0
  12. package/dist/guestCredential.js +11 -0
  13. package/dist/guestPasswordUpgrade.js +84 -0
  14. package/dist/guestPlugin.js +165 -0
  15. package/dist/guestSchema.js +19 -0
  16. package/dist/guestSecrets.js +9 -0
  17. package/dist/guestService.js +223 -0
  18. package/dist/handlers.js +114 -115
  19. package/dist/impersonation.js +80 -80
  20. package/dist/index.d.ts +265 -15
  21. package/dist/index.js +27 -19
  22. package/dist/invitations.js +24 -25
  23. package/dist/mail.js +1 -2
  24. package/dist/memberships.js +1 -2
  25. package/dist/native/guestStorage.js +40 -0
  26. package/dist/native.d.ts +110 -0
  27. package/dist/native.js +46 -45
  28. package/dist/nativeRoutes.js +62 -74
  29. package/dist/overlayPlugin.js +166 -0
  30. package/dist/passkeyHandlers.js +1 -2
  31. package/dist/password.js +1 -2
  32. package/dist/plugin.d.ts +119 -10
  33. package/dist/plugin.js +100 -80
  34. package/dist/postgresUserStore.js +20 -9
  35. package/dist/revocation.js +41 -25
  36. package/dist/schema.d.ts +41 -1
  37. package/dist/schema.js +100 -100
  38. package/dist/session.js +1 -2
  39. package/dist/sessionFamily.js +14 -0
  40. package/dist/sessionRegistry.js +94 -80
  41. package/dist/signedClaims.js +23 -0
  42. package/dist/strategy.d.ts +13 -9
  43. package/dist/strategy.js +27 -24
  44. package/dist/subjectGuard.js +1 -2
  45. package/dist/tokens.js +1 -2
  46. package/dist/totp.js +1 -2
  47. package/dist/userStore.js +146 -140
  48. package/dist/web/guestStorage.js +46 -0
  49. package/dist/web/passkeyClient.js +1 -2
  50. package/dist/web/requireAuth.js +1 -2
  51. package/dist/web/subjectContext.js +1 -2
  52. package/dist/web.d.ts +94 -0
  53. package/dist/web.js +4 -2
  54. package/dist/webauthn.js +1 -2
  55. package/package.json +9 -4
@@ -0,0 +1,166 @@
1
+ import { sessionSecretsOf as e } from "./session.js";
2
+ import { isSessionCredentialLiveFor as t } from "./sessionFamily.js";
3
+ import { dataStoreSessionRegistry as n } from "./sessionRegistry.js";
4
+ import { validateGuestSecrets as r } from "./guestSecrets.js";
5
+ import { authTokensTable as i, sessionsTable as a } from "./schema.js";
6
+ import { createCipheriv as o, createDecipheriv as s, createHash as c, hkdfSync as l, randomBytes as u } from "node:crypto";
7
+ import { Duration as d, Effect as f } from "effect";
8
+ import { SESSION_COOKIE_NAME as p, SESSION_HEADER_NAME as m, buildSetCookie as h, resolveSessionSecrets as g, verifySessionKeyed as _ } from "@voltro/protocol/session";
9
+ import { definePlugin as v } from "@voltro/protocol";
10
+ // src/overlayPlugin.ts
11
+ var y = "overlay-code", b = "authTokens", x = 60, S = 300, C = (e, t) => ({
12
+ status: e,
13
+ body: JSON.stringify(t),
14
+ contentType: "application/json",
15
+ headers: { "cache-control": "no-store" }
16
+ }), w = (e) => `ovl_${c("sha256").update(e).digest("hex")}`, T = (e) => Buffer.from(l("sha256", e, "voltro:web-overlay:v1", "overlay-code", 32)), E = (e, t) => {
17
+ let n = u(12), r = o("aes-256-gcm", T(t), n), i = Buffer.concat([r.update(JSON.stringify({
18
+ v: e.value,
19
+ n: e.next,
20
+ exp: e.exp
21
+ }), "utf8"), r.final()]);
22
+ return Buffer.concat([
23
+ n,
24
+ r.getAuthTag(),
25
+ i
26
+ ]).toString("base64url");
27
+ }, D = (e, t, n) => {
28
+ if (e.length < 40 || e.length > 12e3 || !/^[A-Za-z0-9_-]+$/.test(e)) return null;
29
+ let r = Buffer.from(e, "base64url");
30
+ if (r.length < 29) return null;
31
+ for (let e of t) try {
32
+ let t = s("aes-256-gcm", T(e), r.subarray(0, 12));
33
+ t.setAuthTag(r.subarray(12, 28));
34
+ let i = JSON.parse(Buffer.concat([t.update(r.subarray(28)), t.final()]).toString("utf8"));
35
+ return typeof i.v != "string" || typeof i.n != "string" || typeof i.exp != "number" || i.exp <= n ? null : {
36
+ value: i.v,
37
+ next: i.n,
38
+ exp: i.exp
39
+ };
40
+ } catch {}
41
+ return null;
42
+ }, O = (e) => !/^\/[A-Za-z0-9/_.~-]*$/.test(e) || e.startsWith("//") || e.split("/").some((e) => e === ".." || e === ".") ? null : e.length > 1 && e.endsWith("/") ? e.slice(0, -1) : e, k = (e, t) => {
43
+ if (e.length > 512 || /[\u0000-\u001f\\#]/.test(e)) return !1;
44
+ let n = O(e.split("?")[0] ?? "");
45
+ return n !== null && t.some((e) => e === "/" || n === e || n.startsWith(e + "/"));
46
+ }, A = async (r, i, a, o) => {
47
+ let s = _(r, e(i));
48
+ if (!s || s.subject.type !== "user") return null;
49
+ let c = s.subject.metadata?.sessionId;
50
+ if (typeof c != "string") return null;
51
+ let l = await f.runPromise(n(a).findSession(c));
52
+ return t(l, c, s.subject.id, o) ? {
53
+ subjectId: s.subject.id,
54
+ exp: s.exp
55
+ } : null;
56
+ }, j = (t) => {
57
+ let n = t.prefix ?? "/auth/overlay";
58
+ if (!/^\/[a-zA-Z0-9/_-]+$/.test(n) || n.endsWith("/")) throw Error("Overlay route prefix must be an absolute path without a trailing slash");
59
+ let o = t.paths.map((e) => O(e));
60
+ if (o.length === 0 || o.some((e) => e === null)) throw Error("Overlay paths must be absolute same-origin paths without traversal");
61
+ let s = o, c = d.toSeconds(t.ttl ?? `${x} seconds`);
62
+ if (!Number.isSafeInteger(c) || c < 1 || c > S) throw Error("Overlay ticket lifetime must be 1 to 300 whole seconds");
63
+ let l = () => r(t.secret ?? g()), _, T = async (t, r) => {
64
+ if (t.method !== "POST") return {
65
+ ...C(405, { error: "method_not_allowed" }),
66
+ headers: {
67
+ "cache-control": "no-store",
68
+ allow: "POST"
69
+ }
70
+ };
71
+ if ((t.headers["content-type"] ?? "").split(";")[0]?.trim() !== "application/json") return C(415, { error: "json_required" });
72
+ if (t.rawBody.byteLength > 1024) return C(413, { error: "body_too_large" });
73
+ let i = t.headers[m];
74
+ if (!i) return C(401, { error: "session_header_required" });
75
+ let a;
76
+ try {
77
+ a = JSON.parse(new TextDecoder().decode(t.rawBody));
78
+ } catch {
79
+ return C(400, { error: "invalid_input" });
80
+ }
81
+ if (typeof a != "object" || !a || Array.isArray(a) || Object.keys(a).some((e) => e !== "next") || typeof a.next != "string") return C(400, { error: "invalid_input" });
82
+ let o = a.next;
83
+ if (!k(o, s)) return C(400, { error: "page_not_allowed" });
84
+ let d = Date.now(), f = await A(i, l(), r, d);
85
+ if (!f) return C(401, { error: "session_rejected" });
86
+ let p = Math.min(Math.floor(d / 1e3) + c, f.exp), h = u(32).toString("base64url");
87
+ await r.insert(b, {
88
+ id: w(h),
89
+ tokenHash: w(h).slice(4),
90
+ userId: f.subjectId,
91
+ purpose: y,
92
+ expiresAt: /* @__PURE__ */ new Date(p * 1e3),
93
+ consumedAt: null
94
+ });
95
+ let g = `${h}.${E({
96
+ value: i,
97
+ next: o,
98
+ exp: p
99
+ }, e(l()).current.secret)}`;
100
+ return C(200, {
101
+ url: `${n}/enter?code=${g}`,
102
+ expiresAt: p
103
+ });
104
+ }, j = async (n, r) => {
105
+ if (n.method !== "GET") return {
106
+ ...C(405, { error: "method_not_allowed" }),
107
+ headers: {
108
+ "cache-control": "no-store",
109
+ allow: "GET"
110
+ }
111
+ };
112
+ let i = new URLSearchParams(n.query).get("code") ?? "", a = i.indexOf("."), o = i.slice(0, a);
113
+ if (a <= 0 || !/^[A-Za-z0-9_-]{43}$/.test(o)) return C(400, { error: "invalid_code" });
114
+ let c = e(l()), u = Date.now(), d = D(i.slice(a + 1), [c.current.secret, ...c.previous ? [c.previous.secret] : []], Math.floor(u / 1e3));
115
+ if (!await r.delete(b, w(o)) || !d || !k(d.next, s)) return C(400, { error: "invalid_code" });
116
+ if (!await A(d.value, c, r, u)) return C(401, { error: "session_rejected" });
117
+ let f = h(p, d.value, {
118
+ httpOnly: !0,
119
+ sameSite: "lax",
120
+ secure: t.secure ?? !0,
121
+ path: "/",
122
+ ...t.domain ? { domain: t.domain } : {}
123
+ });
124
+ return {
125
+ status: 302,
126
+ body: "",
127
+ headers: {
128
+ location: d.next,
129
+ "set-cookie": f,
130
+ "cache-control": "no-store",
131
+ "referrer-policy": "no-referrer"
132
+ }
133
+ };
134
+ };
135
+ return v({
136
+ name: "@voltro/plugin-auth#overlay",
137
+ baseName: "@voltro/plugin-auth",
138
+ permissions: ["store:write"],
139
+ extendSchema: { tables: [i, a] },
140
+ declaredEnv: t.secret === void 0 ? [{
141
+ name: "VOLTRO_SESSION_SECRET",
142
+ required: !0,
143
+ secret: !0,
144
+ generate: "base64url",
145
+ description: "Signs this project's sessions and seals web overlay tickets"
146
+ }] : [],
147
+ httpRoutes: [{
148
+ method: "*",
149
+ path: n,
150
+ handle: async (e) => {
151
+ let t = e.store ?? _;
152
+ return e.path !== n + "/ticket" && e.path !== n + "/enter" ? C(404, { error: "not_found" }) : t ? e.path === n + "/ticket" ? T(e, t) : j(e, t) : C(503, { error: "store_unavailable" });
153
+ }
154
+ }],
155
+ bindDataStore: (e) => {
156
+ _ = e;
157
+ },
158
+ onActivate: () => f.sync(() => {
159
+ l();
160
+ }),
161
+ onDeactivate: () => f.sync(() => {
162
+ _ = void 0;
163
+ })
164
+ });
165
+ };
166
+ export { y as OVERLAY_TOKEN_PURPOSE, k as allowedOverlayPage, j as overlayAuthPlugin };
@@ -3,7 +3,7 @@ import { newAuthId as t, subjectFromUser as n } from "./userStore.js";
3
3
  import { configSessionSecrets as r, guardSubject as i } from "./handlers.js";
4
4
  import { generateChallenge as a, verifyAssertion as o, verifyRegistration as s } from "./webauthn.js";
5
5
  import { Effect as c } from "effect";
6
- //#region src/passkeyHandlers.ts
6
+ // src/passkeyHandlers.ts
7
7
  var l = (e, t, n = {}) => ({
8
8
  status: e,
9
9
  body: JSON.stringify(t),
@@ -158,5 +158,4 @@ var l = (e, t, n = {}) => ({
158
158
  subject: C
159
159
  }, { setCookie: w.setCookie });
160
160
  });
161
- //#endregion
162
161
  export { f as dataStoreChallengeStore, g as handlePasskeyAssertOptions, _ as handlePasskeyAssertVerify, m as handlePasskeyRegisterOptions, h as handlePasskeyRegisterVerify, d as memoryChallengeStore };
package/dist/password.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomBytes as e, scrypt as t, timingSafeEqual as n } from "node:crypto";
2
2
  import { Data as r, Effect as i } from "effect";
3
- //#region src/password.ts
3
+ // src/password.ts
4
4
  var a = 32768, o = 8, s = 1, c = 32, l = 16, u = (e, t) => 128 * e * t * 2, d = 268435456, f = class extends r.TaggedError("PasswordEmptyError") {}, p = class extends r.TaggedError("PasswordHashError") {}, m = (e, n, r, a) => i.callback((o) => {
5
5
  t(e, n, r, {
6
6
  ...a,
@@ -71,5 +71,4 @@ var a = 32768, o = 8, s = 1, c = 32, l = 16, u = (e, t) => 128 * e * t * 2, d =
71
71
  rehash: n
72
72
  } : { valid: !0 };
73
73
  });
74
- //#endregion
75
74
  export { f as PasswordEmptyError, p as PasswordHashError, h as hashPassword, y as needsRehash, v as parseScryptParams, g as verifyPassword, b as verifyPasswordWithRehash };
package/dist/plugin.d.ts CHANGED
@@ -5,6 +5,7 @@ import { Effect } from 'effect';
5
5
  import { SessionSecrets } from '@voltro/protocol/session';
6
6
  import { SqlClient } from 'effect/sql/SqlClient';
7
7
  import { Subject } from '@voltro/protocol';
8
+ import { SubjectIdentity } from '@voltro/protocol';
8
9
  import { VoidIfEmpty } from 'effect/Types';
9
10
  import { VoltroPlugin } from '@voltro/protocol';
10
11
  import { YieldableError } from 'effect/Cause';
@@ -124,7 +125,7 @@ declare interface AuthConfig {
124
125
  * routes use) so the framework's serve pipeline can slot it into the
125
126
  * per-app auth chain — the rpc/WS path then rejects revoked sessions
126
127
  * exactly like the `/auth/*` routes. Read it via
127
- * `getAuthSessionStrategy`.
128
+ * `getAuthSessionStrategies`.
128
129
  */
129
130
  export declare interface AuthPlugin extends VoltroPlugin {
130
131
  readonly auth: {
@@ -174,6 +175,15 @@ export declare interface AuthRoutesPluginOptions extends AuthConfig {
174
175
  * otherwise get 500s from a route it never asked for.
175
176
  */
176
177
  readonly invitations?: InvitationsConfig;
178
+ /**
179
+ * Guest → account with email and password. PRESENT mounts `POST /auth/upgrade-guest`
180
+ * (`{ credential, email, password }`, CSRF): the guest becomes a registered account under its
181
+ * own subject ID, so what it owns stays its own. Pass the installed guest lifecycle port,
182
+ * e.g. `{ service: () => guestPlugin.guest.service() }`. ABSENT mounts nothing.
183
+ */
184
+ readonly guestUpgrade?: {
185
+ readonly service: () => GuestAuthService;
186
+ };
177
187
  /**
178
188
  * User impersonation ("log in as"). PRESENT mounts `/auth/impersonate/start`
179
189
  * + `/stop`, arms the retention bound on `impersonationGrants`, and closes
@@ -242,11 +252,12 @@ declare interface EmailVerificationConfig {
242
252
  declare type EmailVerificationPolicy = 'off' | 'soft' | 'strict';
243
253
 
244
254
  /**
245
- * Pull the pre-wired session strategy off a plugin list (the framework's
246
- * serve pipeline calls this when composing the auth chain). Returns the
247
- * FIRST auth plugin's strategy, or null when no auth plugin is present.
255
+ * Collect pre-wired session strategies in plugin declaration order. The
256
+ * framework inserts each original strategy into its shared auth chain, so
257
+ * prefix ordering, collision diagnostics and boot verification see them all.
258
+ * Only `skip` advances; rejected credentials never get another attempt.
248
259
  */
249
- export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugin>) => AuthStrategy | null;
260
+ export declare const getAuthSessionStrategies: (plugins: ReadonlyArray<VoltroPlugin>) => ReadonlyArray<AuthStrategy>;
250
261
 
251
262
  declare interface GrantRoleContext {
252
263
  /** The inviter's role in THIS tenant, as read from `memberships`. */
@@ -256,6 +267,91 @@ declare interface GrantRoleContext {
256
267
  readonly tenantId: string;
257
268
  }
258
269
 
270
+ declare class GuestAuthError extends GuestAuthError_base<{
271
+ readonly code: 'credential_rejected' | 'store_unavailable' | 'upgrade_conflict';
272
+ }> {
273
+ }
274
+
275
+ declare const GuestAuthError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
276
+ readonly _tag: "GuestAuthError";
277
+ } & Readonly<A>;
278
+
279
+ /** Durable guest lifecycle port. Implementations must serialize every write
280
+ * against the guest identity and commit session rows before returning credentials. */
281
+ declare interface GuestAuthService {
282
+ readonly create: () => Effect.Effect<GuestSession, GuestAuthError>;
283
+ readonly resume: (credential: string) => Effect.Effect<GuestSession, GuestAuthError>;
284
+ /** Propose a fresh server-minted proof without replacing the active proof. */
285
+ readonly prepareRotation: (credential: string) => Effect.Effect<{
286
+ readonly deviceCredential: string;
287
+ }, GuestAuthError>;
288
+ /** The caller durably journals both proofs before this atomic replacement. */
289
+ readonly rotate: (credential: string, nextCredential: string) => Effect.Effect<GuestSession, GuestAuthError>;
290
+ readonly revoke: (credential: string) => Effect.Effect<void, GuestAuthError>;
291
+ /** Server-side privacy hook: the caller must authorize this subject ID. */
292
+ readonly erase: (subjectId: string) => Effect.Effect<boolean, GuestAuthError>;
293
+ /** Server-side privacy hook: excludes credential hashes and credential bytes. */
294
+ readonly export: (subjectId: string) => Effect.Effect<GuestExport | null, GuestAuthError>;
295
+ readonly isLive: (subject: SubjectIdentity) => Effect.Effect<boolean, GuestAuthError>;
296
+ /** Trusted server operation. The writer must use this transaction to create
297
+ * the registered `users` row with exactly the guest ID and tenant. The guest
298
+ * fence, writer, device removal and guest-session revocation commit together.
299
+ * No client-supplied kind/state can authorize an upgrade. */
300
+ readonly upgrade: <A>(credential: string, writer: GuestUpgradeWriter<A>) => Effect.Effect<A, GuestAuthError>;
301
+ /** Trusted, separately confirmed merge into an EXISTING registered subject.
302
+ * The coordinator must prove target ownership and run all data hooks through
303
+ * this same transaction. A device proof alone never proves target ownership. */
304
+ readonly mergeInto: <A>(credential: string, targetId: string, writer: GuestMergeWriter<A>) => Effect.Effect<A, GuestAuthError>;
305
+ }
306
+
307
+ declare interface GuestExport {
308
+ readonly subject: {
309
+ readonly id: string;
310
+ readonly tenantId: string;
311
+ readonly kind: 'guest';
312
+ readonly upgradeable: boolean;
313
+ readonly createdAt: number;
314
+ readonly expiresAt: number;
315
+ };
316
+ readonly devices: ReadonlyArray<{
317
+ readonly id: string;
318
+ readonly generation: number;
319
+ readonly createdAt: number;
320
+ readonly expiresAt: number;
321
+ }>;
322
+ readonly sessions: ReadonlyArray<{
323
+ readonly id: string;
324
+ readonly expiresAt: number;
325
+ }>;
326
+ }
327
+
328
+ declare type GuestIdentity = Extract<SubjectIdentity, {
329
+ readonly type: 'user';
330
+ }> & {
331
+ readonly kind: 'guest';
332
+ };
333
+
334
+ declare interface GuestMergeContext extends GuestUpgradeContext {
335
+ readonly targetId: string;
336
+ }
337
+
338
+ declare type GuestMergeWriter<A> = (context: GuestMergeContext) => Promise<A>;
339
+
340
+ declare interface GuestSession extends IssuedRegisteredSession {
341
+ readonly subject: GuestIdentity;
342
+ /** Opaque proof returned only on creation or explicit credential rotation. */
343
+ readonly deviceCredential?: string;
344
+ }
345
+
346
+ declare interface GuestUpgradeContext {
347
+ readonly store: DataStore;
348
+ readonly subjectId: string;
349
+ readonly tenantId: string;
350
+ readonly createdAt: Date;
351
+ }
352
+
353
+ declare type GuestUpgradeWriter<A> = (context: GuestUpgradeContext) => Promise<A>;
354
+
259
355
  /** Identity — who a user is. The only role an app with its own identity table has to write itself; everything else composes from the shipped store. */
260
356
  declare interface IdentityStore {
261
357
  readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
@@ -470,6 +566,18 @@ declare interface InvitationStore {
470
566
  readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
471
567
  }
472
568
 
569
+ declare interface IssuedRegisteredSession extends IssuedSession {
570
+ /** The row's id — what `revokeSession` takes. */
571
+ readonly sessionId: string;
572
+ }
573
+
574
+ declare interface IssuedSession {
575
+ /** The opaque cookie value — `<payload>.<sig>`. */
576
+ readonly value: string;
577
+ /** Ready-to-Set-Cookie header string with sane defaults. */
578
+ readonly setCookie: string;
579
+ }
580
+
473
581
  /** Someone about to join a tenant. */
474
582
  declare interface Joining {
475
583
  readonly email: string;
@@ -679,13 +787,14 @@ declare interface SessionRevocationOptions {
679
787
 
680
788
  /** Server-side sessions — enumeration + revocation. */
681
789
  declare interface SessionStore {
682
- /** Active sessions for a user (most recent first). */
790
+ /** Unrotated credentials with an existing canonical root owned by the same user, most recent first. */
683
791
  readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
684
792
  /** Record a session on sign-in. */
685
793
  readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
686
- /** Look up one session row by id. The request-time revocation check
687
- * (`makeSessionRevocationChecker`) calls this — a missing row means
688
- * the session was revoked. */
794
+ /** Look up an unrotated credential only while its canonical family root
795
+ * exists and belongs to the same user. A rotated root remains an anchor;
796
+ * its tenant does not replace the credential tenant. Custom stores must
797
+ * enforce this before returning a row to request-time revocation checks. */
689
798
  readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
690
799
  /** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
691
800
  * when the sliding-window renewal re-issues the cookie, so the row
@@ -777,7 +886,7 @@ declare const UserNotFoundError_base: new <A extends Record<string, any> = {}>(a
777
886
 
778
887
  declare interface UserRecord {
779
888
  readonly id: string;
780
- readonly email: string;
889
+ readonly email: string | null;
781
890
  /**
782
891
  * The stored scrypt hash — OPTIONAL, because not every identity has a
783
892
  * password. An SSO-only, magic-link-only, passkey-only, or provider-PAT