@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.
@@ -0,0 +1,236 @@
1
+ import { sessionSecretsOf as e } from "./session.js";
2
+ import { Data as t, Effect as n } from "effect";
3
+ import { readCookie as r, resolveSessionSecret as i, verifySessionKeyed as a } from "@voltro/protocol/session";
4
+ import { generateId as o } from "@voltro/database";
5
+ //#region src/revocation.ts
6
+ var s = 3e4, c = 1e4, l = (e, t = {}) => {
7
+ let r = t.ttlMs ?? s, i = t.maxEntries ?? c, a = t.now ?? Date.now, o = /* @__PURE__ */ new Map(), l = () => {
8
+ if (o.size <= i) return;
9
+ let e = a();
10
+ for (let [t, n] of o) n.freshUntil <= e && o.delete(t);
11
+ for (let e of o.keys()) {
12
+ if (o.size <= i) break;
13
+ o.delete(e);
14
+ }
15
+ }, u = (e) => e !== null && e.expiresAt.getTime() > a();
16
+ return {
17
+ ttlMs: r,
18
+ invalidate: (e) => {
19
+ o.delete(e);
20
+ },
21
+ isLive: (t) => n.gen(function* () {
22
+ let n = a();
23
+ if (r > 0) {
24
+ let e = o.get(t);
25
+ if (e && e.freshUntil > n) return e.live;
26
+ }
27
+ let i = yield* e.findSession(t), s = u(i);
28
+ return r > 0 && (o.set(t, {
29
+ live: s,
30
+ freshUntil: n + r
31
+ }), l()), s;
32
+ })
33
+ };
34
+ }, u = class extends t.TaggedError("UserAlreadyExistsError") {}, d = class extends t.TaggedError("UserNotFoundError") {}, f = (e) => o({
35
+ kind: "typeid",
36
+ prefix: e
37
+ }, e), p = (e = []) => {
38
+ let t = /* @__PURE__ */ new Map(), r = /* @__PURE__ */ new Map(), i = [], a = [], o = [], s = [], c = [];
39
+ for (let n of e) t.set(n.id, n), r.set(n.email.toLowerCase(), n);
40
+ let l = (e, i) => n.gen(function* () {
41
+ let a = t.get(e);
42
+ if (!a) return yield* n.fail(new d({ userId: e }));
43
+ let o = i(a);
44
+ return t.set(e, o), r.set(o.email.toLowerCase(), o), o;
45
+ });
46
+ return {
47
+ findByEmail: (e) => n.sync(() => r.get(e.toLowerCase()) ?? null),
48
+ findById: (e) => n.sync(() => t.get(e) ?? null),
49
+ insert: (e) => n.gen(function* () {
50
+ let i = e.email.toLowerCase();
51
+ if (r.has(i)) return yield* n.fail(new u({ email: i }));
52
+ let a = {
53
+ ...e,
54
+ email: i,
55
+ createdAt: /* @__PURE__ */ new Date()
56
+ };
57
+ return t.set(a.id, a), r.set(i, a), a;
58
+ }),
59
+ updatePassword: (e, t) => l(e, (e) => ({
60
+ ...e,
61
+ passwordHash: t
62
+ })),
63
+ setMfaSecret: (e, t) => l(e, (e) => ({
64
+ ...e,
65
+ mfaSecret: t,
66
+ mfaEnrolledAt: null
67
+ })),
68
+ markMfaEnrolled: (e) => l(e, (e) => ({
69
+ ...e,
70
+ mfaEnrolledAt: /* @__PURE__ */ new Date()
71
+ })),
72
+ clearMfa: (e) => l(e, (e) => ({
73
+ ...e,
74
+ mfaSecret: null,
75
+ mfaEnrolledAt: null
76
+ })),
77
+ listMemberships: (e) => n.sync(() => i.filter((t) => t.userId === e)),
78
+ addMembership: (e) => n.sync(() => {
79
+ let t = i.findIndex((t) => t.userId === e.userId && t.tenantId === e.tenantId), n = {
80
+ ...e,
81
+ joinedAt: /* @__PURE__ */ new Date()
82
+ };
83
+ if (t >= 0) {
84
+ let e = i[t], r = {
85
+ ...n,
86
+ joinedAt: e.joinedAt
87
+ };
88
+ return i[t] = r, r;
89
+ }
90
+ return i.push(n), n;
91
+ }),
92
+ membershipRole: (e, t) => n.sync(() => i.find((n) => n.userId === e && n.tenantId === t)?.role ?? null),
93
+ listSessions: (e) => n.sync(() => a.filter((t) => t.userId === e).sort((e, t) => t.createdAt.getTime() - e.createdAt.getTime())),
94
+ insertSession: (e) => n.sync(() => {
95
+ let t = /* @__PURE__ */ new Date(), n = {
96
+ ...e,
97
+ createdAt: t,
98
+ lastSeenAt: t
99
+ };
100
+ return a.push(n), n;
101
+ }),
102
+ findSession: (e) => n.sync(() => a.find((t) => t.id === e) ?? null),
103
+ touchSession: (e, t) => n.sync(() => {
104
+ let n = a.findIndex((t) => t.id === e);
105
+ n >= 0 && (a[n] = {
106
+ ...a[n],
107
+ expiresAt: t,
108
+ lastSeenAt: /* @__PURE__ */ new Date()
109
+ });
110
+ }),
111
+ revokeSession: (e, t) => n.sync(() => {
112
+ let n = a.findIndex((n) => n.id === t && n.userId === e);
113
+ return n < 0 ? !1 : (a.splice(n, 1), !0);
114
+ }),
115
+ revokeAllOtherSessions: (e, t) => n.sync(() => {
116
+ let n = 0;
117
+ for (let r = a.length - 1; r >= 0; r--) {
118
+ let i = a[r];
119
+ i.userId === e && i.id !== t && (a.splice(r, 1), n++);
120
+ }
121
+ return n;
122
+ }),
123
+ insertToken: (e) => n.sync(() => {
124
+ let t = {
125
+ ...e,
126
+ consumedAt: null,
127
+ createdAt: /* @__PURE__ */ new Date()
128
+ };
129
+ return o.push(t), t;
130
+ }),
131
+ consumeToken: (e, t) => n.sync(() => {
132
+ let n = Date.now(), r = o.find((r) => r.tokenHash === e && r.purpose === t && r.consumedAt === null && r.expiresAt.getTime() > n);
133
+ if (!r) return null;
134
+ let i = {
135
+ ...r,
136
+ consumedAt: /* @__PURE__ */ new Date()
137
+ }, a = o.indexOf(r);
138
+ return o[a] = i, i;
139
+ }),
140
+ replaceRecoveryCodes: (e, t) => n.sync(() => {
141
+ for (let t = c.length - 1; t >= 0; t--) c[t].userId === e && c.splice(t, 1);
142
+ let n = /* @__PURE__ */ new Date();
143
+ for (let r of t) c.push({
144
+ id: f("rc"),
145
+ userId: e,
146
+ codeHash: r,
147
+ consumedAt: null,
148
+ createdAt: n
149
+ });
150
+ }),
151
+ consumeRecoveryCode: (e, t) => n.sync(() => {
152
+ let n = c.findIndex((n) => n.userId === e && n.codeHash === t && n.consumedAt === null);
153
+ return n < 0 ? !1 : (c[n] = {
154
+ ...c[n],
155
+ consumedAt: /* @__PURE__ */ new Date()
156
+ }, !0);
157
+ }),
158
+ countRecoveryCodes: (e) => n.sync(() => c.filter((t) => t.userId === e && t.consumedAt === null).length),
159
+ listPasskeys: (e) => n.sync(() => s.filter((t) => t.userId === e)),
160
+ findPasskey: (e) => n.sync(() => s.find((t) => t.credentialId === e) ?? null),
161
+ insertPasskey: (e) => n.sync(() => {
162
+ let t = /* @__PURE__ */ new Date(), n = {
163
+ ...e,
164
+ createdAt: t,
165
+ lastUsedAt: null
166
+ };
167
+ return s.push(n), n;
168
+ }),
169
+ advancePasskeyCounter: (e, t) => n.sync(() => {
170
+ let n = s.findIndex((t) => t.credentialId === e);
171
+ return n < 0 || t <= s[n].counter ? !1 : (s[n] = {
172
+ ...s[n],
173
+ counter: t,
174
+ lastUsedAt: /* @__PURE__ */ new Date()
175
+ }, !0);
176
+ })
177
+ };
178
+ }, m = (e, t = {}) => ({
179
+ type: "user",
180
+ id: e.id,
181
+ tenantId: t.tenantId ?? e.tenantId,
182
+ ...t.memberships ? { metadata: { memberships: t.memberships.map((e) => ({
183
+ tenantId: e.tenantId,
184
+ role: e.role
185
+ })) } } : {}
186
+ }), h = (e) => {
187
+ if (e.type !== "user") return null;
188
+ let t = e.metadata?.sessionId;
189
+ return typeof t == "string" && t.length > 0 ? t : null;
190
+ }, g = (e) => {
191
+ if (e.type !== "user") return [];
192
+ let t = e.metadata?.memberships;
193
+ return Array.isArray(t) ? t.filter((e) => typeof e == "object" && !!e && typeof e.tenantId == "string" && typeof e.role == "string") : [];
194
+ }, _ = (e) => typeof e.isLive == "function", v = (e) => e.revocation && _(e.revocation) ? e.revocation : e.store ? l(e.store, e.revocation ?? {}) : null, y = (t = {}) => {
195
+ let o = t.cookieName ?? "voltro:session", s = v(t);
196
+ return {
197
+ id: "voltro-password",
198
+ resolve: (c) => {
199
+ let l = r(c.headers.cookie, o);
200
+ if (!l) return { kind: "skip" };
201
+ let u = a(l, e(t.secret ?? i()));
202
+ if (!u) return {
203
+ kind: "failed",
204
+ reason: "voltro session cookie failed verification"
205
+ };
206
+ let d = b(u.subject, "voltro-password");
207
+ if (!s) return {
208
+ kind: "matched",
209
+ subject: d
210
+ };
211
+ let f = h(d);
212
+ return f ? n.runPromise(s.isLive(f)).then((e) => e ? {
213
+ kind: "matched",
214
+ subject: d
215
+ } : {
216
+ kind: "failed",
217
+ reason: "voltro session revoked"
218
+ }) : {
219
+ kind: "matched",
220
+ subject: d
221
+ };
222
+ }
223
+ };
224
+ }, b = (e, t) => {
225
+ if (e.type === "anonymous") return e;
226
+ let n = e.metadata ?? {};
227
+ return {
228
+ ...e,
229
+ metadata: {
230
+ ...n,
231
+ provider: t
232
+ }
233
+ };
234
+ };
235
+ //#endregion
236
+ export { f as a, g as c, p as i, l, u as n, h as o, d as r, m as s, y as t };
@@ -0,0 +1,233 @@
1
+ import { AuthStrategy } from '@voltro/protocol';
2
+ import { Effect } from 'effect';
3
+ import { SessionSecrets } from '@voltro/protocol/session';
4
+ import { VoidIfEmpty } from 'effect/Types';
5
+ import { YieldableError } from 'effect/Cause';
6
+
7
+ /** One tenant a user belongs to. The active tenant lives on the Subject;
8
+ * the full set drives the switch-tenant menu + the switch-tenant guard. */
9
+ declare interface MembershipRecord {
10
+ readonly userId: string;
11
+ readonly tenantId: string;
12
+ readonly role: string;
13
+ readonly joinedAt: Date;
14
+ }
15
+
16
+ /** A registered WebAuthn credential. */
17
+ declare interface PasskeyRecord {
18
+ readonly id: string;
19
+ readonly credentialId: string;
20
+ readonly userId: string;
21
+ readonly publicKey: string;
22
+ readonly counter: number;
23
+ readonly transports?: string | null;
24
+ readonly createdAt: Date;
25
+ readonly lastUsedAt: Date | null;
26
+ }
27
+
28
+ /** A server-side session row — enumerated so apps can list active
29
+ * devices + revoke them. Written on sign-in; deleted on revoke. */
30
+ declare interface SessionRecord {
31
+ readonly id: string;
32
+ readonly userId: string;
33
+ readonly tenantId: string;
34
+ readonly expiresAt: Date;
35
+ readonly ipAddress?: string | null;
36
+ readonly userAgent?: string | null;
37
+ readonly createdAt: Date;
38
+ readonly lastSeenAt: Date;
39
+ }
40
+
41
+ declare interface SessionRevocationChecker {
42
+ /** Is this session still live? `false` means the row was revoked (or
43
+ * is past its `expiresAt`) — the caller rejects the cookie. Verdicts
44
+ * are cached for `ttlMs`. */
45
+ readonly isLive: (sessionId: string) => Effect.Effect<boolean>;
46
+ /** Drop the cached verdict for one session — called inline by
47
+ * sign-out / revoke handlers so the kill is immediate on THIS
48
+ * process (other replicas converge within `ttlMs`). */
49
+ readonly invalidate: (sessionId: string) => void;
50
+ /** The effective cache window in milliseconds. */
51
+ readonly ttlMs: number;
52
+ }
53
+
54
+ declare interface SessionRevocationOptions {
55
+ /** Cache window in milliseconds. A revocation performed elsewhere
56
+ * takes effect on this process within this window. Default 30s.
57
+ * `0` disables caching (every verify hits the store). */
58
+ readonly ttlMs?: number;
59
+ /** Upper bound on cached session ids. When exceeded, expired entries
60
+ * are swept; if still over, the oldest entries are dropped. Default
61
+ * 10 000. */
62
+ readonly maxEntries?: number;
63
+ /** Clock override (epoch milliseconds). Defaults to `Date.now`. A
64
+ * testing seam — lets suites cross the cache window without
65
+ * wall-clock sleeps. */
66
+ readonly now?: () => number;
67
+ }
68
+
69
+ /** Every session helper takes either a bare secret string (single-key)
70
+ * or the keyed `{ current, previous? }` rotation set. */
71
+ declare type SessionSecretInput = string | SessionSecrets;
72
+
73
+ declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
74
+
75
+ /** A single-use, hashed, expiring token (magic-link / password-reset). */
76
+ declare interface TokenRecord {
77
+ readonly id: string;
78
+ readonly tokenHash: string;
79
+ readonly userId: string;
80
+ readonly purpose: TokenPurpose;
81
+ readonly expiresAt: Date;
82
+ readonly consumedAt: Date | null;
83
+ readonly createdAt: Date;
84
+ }
85
+
86
+ declare class UserAlreadyExistsError extends UserAlreadyExistsError_base<{
87
+ readonly email: string;
88
+ }> {
89
+ }
90
+
91
+ 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 & {
92
+ readonly _tag: "UserAlreadyExistsError";
93
+ } & Readonly<A>;
94
+
95
+ declare class UserNotFoundError extends UserNotFoundError_base<{
96
+ readonly userId: string;
97
+ }> {
98
+ }
99
+
100
+ 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 & {
101
+ readonly _tag: "UserNotFoundError";
102
+ } & Readonly<A>;
103
+
104
+ declare interface UserRecord {
105
+ readonly id: string;
106
+ readonly email: string;
107
+ readonly passwordHash: string;
108
+ readonly tenantId: string;
109
+ readonly createdAt: Date;
110
+ /** Base32-encoded TOTP secret. `null` = MFA not enrolled. Set by
111
+ * `setMfaSecret()` from the enrolment handler. Stored as plaintext. */
112
+ readonly mfaSecret?: string | null;
113
+ /** Timestamp the user completed TOTP enrolment (first successful
114
+ * verify). Distinct from the secret being set — a half-completed
115
+ * enrolment leaves `mfaSecret` populated but this null. */
116
+ readonly mfaEnrolledAt?: Date | null;
117
+ /** Optional lifecycle flag read by post-authentication subject guards.
118
+ * Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin-
119
+ * deactivation`'s `deactivationGuard()` vetoes login when it's non-null.
120
+ * `null` / absent means active. A store populates it from the app's
121
+ * `deactivatedAt` column (added by the `deactivation()` mixin) when that
122
+ * column exists; stores without the column simply leave it undefined and
123
+ * the guard treats the account as active. */
124
+ readonly deactivatedAt?: Date | null;
125
+ }
126
+
127
+ declare interface UserStore {
128
+ readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
129
+ readonly findById: (id: string) => Effect.Effect<UserRecord | null>;
130
+ readonly insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>;
131
+ /** Replace a user's stored password hash. Used by password-reset AND
132
+ * by rehash-on-verify (silently upgrading an under-cost hash on a
133
+ * successful sign-in). */
134
+ readonly updatePassword: (userId: string, newHash: string) => Effect.Effect<UserRecord, UserNotFoundError>;
135
+ /** Stash a freshly-generated TOTP secret on the user. Called by
136
+ * the MFA enrolment handler before the user verifies the first
137
+ * code. */
138
+ readonly setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>;
139
+ /** Mark MFA as fully enrolled (called after the first successful
140
+ * TOTP verify completes the enrolment ceremony). */
141
+ readonly markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
142
+ /** Wipe the secret + enrolledAt. Used by /account/security's
143
+ * "remove MFA" action. */
144
+ readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
145
+ /** Every tenant the user belongs to. */
146
+ readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
147
+ /** Idempotent grant: inserts the [userId, tenantId] membership or
148
+ * updates its role if it already exists. The DB-level composite
149
+ * UNIQUE on `[userId, tenantId]` (see `membershipsTable`) is the
150
+ * source of truth; this upsert converges to it. */
151
+ readonly addMembership: (membership: Omit<MembershipRecord, 'joinedAt'>) => Effect.Effect<MembershipRecord>;
152
+ /** Resolve the role for a [userId, tenantId] pair, or null when the
153
+ * user is NOT a member. The switch-tenant handler calls this to
154
+ * validate the requested tenant before rebinding the connection. */
155
+ readonly membershipRole: (userId: string, tenantId: string) => Effect.Effect<string | null>;
156
+ /** Active sessions for a user (most recent first). */
157
+ readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
158
+ /** Record a session on sign-in. */
159
+ readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
160
+ /** Look up one session row by id. The request-time revocation check
161
+ * (`makeSessionRevocationChecker`) calls this — a missing row means
162
+ * the session was revoked. */
163
+ readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
164
+ /** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
165
+ * when the sliding-window renewal re-issues the cookie, so the row
166
+ * outlives the renewed cookie and the revocation check keeps passing. */
167
+ readonly touchSession: (sessionId: string, expiresAt: Date) => Effect.Effect<void>;
168
+ /** Revoke a single session — only if it belongs to `userId` (so a
169
+ * caller can't revoke another user's session by id-guessing). Returns
170
+ * whether a row was removed. */
171
+ readonly revokeSession: (userId: string, sessionId: string) => Effect.Effect<boolean>;
172
+ /** Revoke every session for the user EXCEPT `keepSessionId` — the
173
+ * "sign out other devices" action. Returns the count removed. */
174
+ readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>;
175
+ /** Persist a single-use token (already hashed). */
176
+ readonly insertToken: (token: Omit<TokenRecord, 'consumedAt' | 'createdAt'>) => Effect.Effect<TokenRecord>;
177
+ /** Atomically redeem a token by its hash + purpose: returns the row
178
+ * only when it exists, matches the purpose, is unexpired, and was not
179
+ * already consumed — and marks it consumed in the same step. Returns
180
+ * null otherwise (single-use guard). */
181
+ readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
182
+ /** Replace the user's recovery codes with a fresh (already hashed) set.
183
+ * Called at enrolment / regeneration — wipes any prior codes so the
184
+ * displayed set is the only valid one. */
185
+ readonly replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray<string>) => Effect.Effect<void>;
186
+ /** Atomically redeem one recovery code by its hash: returns true only
187
+ * when an unconsumed row for `userId` matched — and marks it consumed
188
+ * in the same step (single-use). false otherwise. */
189
+ readonly consumeRecoveryCode: (userId: string, codeHash: string) => Effect.Effect<boolean>;
190
+ /** Count the user's remaining (unconsumed) recovery codes — surfaced so
191
+ * the UI can warn when the user is running low. */
192
+ readonly countRecoveryCodes: (userId: string) => Effect.Effect<number>;
193
+ readonly listPasskeys: (userId: string) => Effect.Effect<ReadonlyArray<PasskeyRecord>>;
194
+ readonly findPasskey: (credentialId: string) => Effect.Effect<PasskeyRecord | null>;
195
+ readonly insertPasskey: (passkey: Omit<PasskeyRecord, 'createdAt' | 'lastUsedAt'>) => Effect.Effect<PasskeyRecord>;
196
+ /** Atomically bump the stored signature counter, guarding against a
197
+ * cloned authenticator (WebAuthn §6.1.1): the update only applies when
198
+ * `newCounter` strictly exceeds the stored counter. Returns whether a
199
+ * row was advanced. `false` with an unchanged stored counter is the
200
+ * clone/replay signal the assertion handler rejects on — the check and
201
+ * the write are ONE atomic statement at the store, so two replicas
202
+ * racing the same counter can't both succeed. */
203
+ readonly advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect<boolean>;
204
+ }
205
+
206
+ export declare const voltroPasswordStrategy: (options?: VoltroPasswordStrategyOptions) => AuthStrategy;
207
+
208
+ export declare interface VoltroPasswordStrategyOptions {
209
+ /** Secret(s) used to verify the cookie: a bare string OR a keyed
210
+ * `SessionSecrets` set. Defaults to `resolveSessionSecret()` which
211
+ * reads `VOLTRO_SESSION_SECRET` env with a dev fallback — same
212
+ * resolver used by the mint side, so there's no drift between
213
+ * issuing and verifying. A bare string is widened with the
214
+ * `VOLTRO_SESSION_SECRET_PREVIOUS` env var (`sessionSecretsOf`),
215
+ * so env-var rotation applies either way. */
216
+ readonly secret?: SessionSecretInput;
217
+ /** Cookie name. Defaults to `voltro:session`. Override when running
218
+ * the strategy alongside another tenant on the same domain. */
219
+ readonly cookieName?: string;
220
+ /** The user store whose `sessions` rows back the request-time
221
+ * revocation check. When absent, verification stays purely
222
+ * stateless — a revoked session's cookie keeps working until it
223
+ * expires. */
224
+ readonly store?: UserStore;
225
+ /** Tuning for the revocation check: options for the built-in TTL
226
+ * cache (default window 30s), or a pre-built checker — pass the
227
+ * auth plugin's own checker to share one cache between the HTTP
228
+ * routes and the rpc auth chain. Ignored without a `store`
229
+ * (unless a checker is passed directly). */
230
+ readonly revocation?: SessionRevocationOptions | SessionRevocationChecker;
231
+ }
232
+
233
+ export { }
@@ -0,0 +1,3 @@
1
+ import "./session.js";
2
+ import { t as e } from "./strategy-CQ2YCIcr.js";
3
+ export { e as voltroPasswordStrategy };
@@ -0,0 +1,31 @@
1
+ /** Hash a plaintext token for storage / lookup. SHA-256 is sufficient —
2
+ * the token is high-entropy (32 random bytes), so there's no
3
+ * brute-force surface that would need a slow KDF. */
4
+ export declare const hashToken: (plaintext: string) => string;
5
+
6
+ /** Default magic-link lifetime — short, since it's an inbox round-trip. */
7
+ export declare const MAGIC_LINK_TTL_S: number;
8
+
9
+ export declare interface MintedToken {
10
+ /** The plaintext — put this in the emailed link. Never stored. */
11
+ readonly token: string;
12
+ /** SHA-256 of the plaintext — store this. */
13
+ readonly tokenHash: string;
14
+ /** Unix-ms expiry. */
15
+ readonly expiresAt: Date;
16
+ readonly purpose: TokenPurpose;
17
+ }
18
+
19
+ /**
20
+ * Mint a fresh single-use token. The caller persists `{ tokenHash,
21
+ * userId, purpose, expiresAt }` via `UserStore.insertToken` and emails
22
+ * the plaintext `token` in a link.
23
+ */
24
+ export declare const mintToken: (purpose: TokenPurpose, ttlSeconds?: number) => MintedToken;
25
+
26
+ /** Default password-reset lifetime — a bit longer. */
27
+ export declare const PASSWORD_RESET_TTL_S: number;
28
+
29
+ declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
30
+
31
+ export { }
package/dist/tokens.js ADDED
@@ -0,0 +1,13 @@
1
+ import { createHash as e, randomBytes as t } from "node:crypto";
2
+ //#region src/tokens.ts
3
+ var n = 900, r = 3600, i = (e) => e.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), a = (t) => e("sha256").update(t).digest("hex"), o = (e, n) => {
4
+ let r = i(t(32)), o = n ?? (e === "magic-link" ? 900 : 3600);
5
+ return {
6
+ token: r,
7
+ tokenHash: a(r),
8
+ expiresAt: new Date(Date.now() + o * 1e3),
9
+ purpose: e
10
+ };
11
+ };
12
+ //#endregion
13
+ export { n as MAGIC_LINK_TTL_S, r as PASSWORD_RESET_TTL_S, a as hashToken, o as mintToken };
package/dist/web.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { ReactNode } from 'react';
2
+ import { Subject } from '@voltro/protocol';
3
+
4
+ /** True when this browser can do WebAuthn at all. */
5
+ export declare const isPasskeySupported: () => boolean;
6
+
7
+ export declare interface PasskeyClientOptions {
8
+ /** Base path the auth routes are mounted under. Default `/auth`. */
9
+ readonly basePath?: string;
10
+ /** Pass the CSRF token (from the `voltro:csrf` cookie / `GET /auth/csrf`)
11
+ * so registration (an authenticated, state-changing flow) passes the
12
+ * double-submit check. */
13
+ readonly csrfToken?: string;
14
+ /** `fetch` override (tests / custom credentials mode). Defaults to the
15
+ * global `fetch` with `credentials: 'include'`. */
16
+ readonly fetchImpl?: typeof fetch;
17
+ }
18
+
19
+ declare interface ProviderProps {
20
+ /** Subject decoded from the session cookie, or null when unsigned. */
21
+ readonly subject: Subject | null;
22
+ readonly children: ReactNode;
23
+ }
24
+
25
+ /**
26
+ * Register a passkey for the (signed-in) user. Runs both ceremony steps:
27
+ * fetch creation options, call `navigator.credentials.create`, post the
28
+ * result for verification. Resolves to the new credential id.
29
+ */
30
+ export declare const registerPasskey: (input: {
31
+ userId: string;
32
+ userName: string;
33
+ }, options?: PasskeyClientOptions) => Promise<{
34
+ credentialId: string;
35
+ }>;
36
+
37
+ export declare const RequireAuth: ({ children, fallback }: RequireAuthProps) => ReactNode;
38
+
39
+ declare interface RequireAuthProps {
40
+ readonly children: ReactNode | ((subject: Subject) => ReactNode);
41
+ /** Rendered when no Subject is present. Typically <LoginCard />. */
42
+ readonly fallback: ReactNode;
43
+ }
44
+
45
+ /**
46
+ * Sign in with a passkey. Fetches assertion options, calls
47
+ * `navigator.credentials.get`, posts the assertion for verification.
48
+ * Resolves to the resolved subject on success.
49
+ */
50
+ export declare const signInWithPasskey: (input?: {
51
+ userId?: string;
52
+ }, options?: PasskeyClientOptions) => Promise<{
53
+ subject: unknown;
54
+ }>;
55
+
56
+ export declare const SubjectProvider: ({ subject, children }: ProviderProps) => ReactNode;
57
+
58
+ /** Read the Subject; returns null when no session is present. */
59
+ export declare const useOptionalSubject: () => Subject | null;
60
+
61
+ /**
62
+ * Read the resolved Subject. THROWS when not signed in — pair with
63
+ * `<RequireAuth>` or with a route-level guard so the throw can't
64
+ * surface to end users. Use `useOptionalSubject()` when both states
65
+ * are valid in the same component.
66
+ */
67
+ export declare const useSubject: () => Subject;
68
+
69
+ export { }
package/dist/web.js ADDED
@@ -0,0 +1,90 @@
1
+ import { createContext as e, useContext as t } from "react";
2
+ import { Fragment as n, jsx as r } from "react/jsx-runtime";
3
+ //#region src/web/subjectContext.tsx
4
+ var i = e(null), a = ({ subject: e, children: t }) => /* @__PURE__ */ r(i.Provider, {
5
+ value: e,
6
+ children: t
7
+ }), o = () => {
8
+ let e = t(i);
9
+ if (!e) throw Error("useSubject() called without a signed-in user. Use useOptionalSubject() or wrap the consumer in <RequireAuth>.");
10
+ return e;
11
+ }, s = () => t(i), c = ({ children: e, fallback: t }) => {
12
+ let i = s();
13
+ return i ? typeof e == "function" ? /* @__PURE__ */ r(n, { children: e(i) }) : /* @__PURE__ */ r(n, { children: e }) : /* @__PURE__ */ r(n, { children: t });
14
+ }, l = (e) => {
15
+ let t = new Uint8Array(e), n = "";
16
+ for (let e of t) n += String.fromCharCode(e);
17
+ return btoa(n).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
+ }, u = (e) => {
19
+ let t = e.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - e.length % 4) % 4), n = atob(t), r = new Uint8Array(n.length);
20
+ for (let e = 0; e < n.length; e++) r[e] = n.charCodeAt(e);
21
+ return r.buffer;
22
+ }, d = () => typeof window < "u" && window.PublicKeyCredential !== void 0 && typeof navigator < "u" && !!navigator.credentials, f = async (e, t, n) => await (await (n.fetchImpl ?? fetch)(e, {
23
+ method: "POST",
24
+ credentials: "include",
25
+ headers: {
26
+ "content-type": "application/json",
27
+ ...n.csrfToken ? { "x-csrf-token": n.csrfToken } : {}
28
+ },
29
+ body: JSON.stringify(t)
30
+ })).json(), p = async (e, t = {}) => {
31
+ if (!d()) throw Error("passkeys not supported in this browser");
32
+ let n = t.basePath ?? "/auth", r = await f(`${n}/passkey/register/options`, e, t), i = r.challenge, a = r.rp, o = r.user, s = r.excludeCredentials ?? [], c = await navigator.credentials.create({ publicKey: {
33
+ challenge: u(i),
34
+ rp: a,
35
+ user: {
36
+ id: new TextEncoder().encode(o.id),
37
+ name: o.name,
38
+ displayName: o.displayName
39
+ },
40
+ pubKeyCredParams: [{
41
+ type: "public-key",
42
+ alg: -7
43
+ }, {
44
+ type: "public-key",
45
+ alg: -257
46
+ }],
47
+ excludeCredentials: s.map((e) => ({
48
+ type: "public-key",
49
+ id: u(e.id)
50
+ })),
51
+ authenticatorSelection: {
52
+ userVerification: "preferred",
53
+ residentKey: "preferred"
54
+ },
55
+ timeout: 300 * 1e3
56
+ } });
57
+ if (!c) throw Error("credential creation was cancelled");
58
+ let p = c.response, m = typeof p.getAuthenticatorData == "function" ? p.getAuthenticatorData() : /* @__PURE__ */ new ArrayBuffer(0), h = await f(`${n}/passkey/register/verify`, {
59
+ userId: e.userId,
60
+ credentialId: l(c.rawId),
61
+ clientDataJSON: l(p.clientDataJSON),
62
+ authenticatorData: l(m)
63
+ }, t);
64
+ if (!h.ok) throw Error(`passkey registration failed: ${String(h.error)}`);
65
+ return { credentialId: h.credentialId };
66
+ }, m = async (e = {}, t = {}) => {
67
+ if (!d()) throw Error("passkeys not supported in this browser");
68
+ let n = t.basePath ?? "/auth", r = await f(`${n}/passkey/assert/options`, e.userId ? { userId: e.userId } : {}, t), i = r.challenge, a = r.rpId, o = r.allowCredentials ?? [], s = await navigator.credentials.get({ publicKey: {
69
+ challenge: u(i),
70
+ rpId: a,
71
+ allowCredentials: o.map((e) => ({
72
+ type: "public-key",
73
+ id: u(e.id)
74
+ })),
75
+ userVerification: "preferred",
76
+ timeout: 300 * 1e3
77
+ } });
78
+ if (!s) throw Error("assertion was cancelled");
79
+ let c = s.response, p = await f(`${n}/passkey/assert/verify`, {
80
+ credentialId: l(s.rawId),
81
+ clientDataJSON: l(c.clientDataJSON),
82
+ authenticatorData: l(c.authenticatorData),
83
+ signature: l(c.signature),
84
+ ...e.userId ? { userId: e.userId } : {}
85
+ }, t);
86
+ if (!p.ok) throw Error(`passkey sign-in failed: ${String(p.error)}`);
87
+ return { subject: p.subject };
88
+ };
89
+ //#endregion
90
+ export { c as RequireAuth, a as SubjectProvider, d as isPasskeySupported, p as registerPasskey, m as signInWithPasskey, s as useOptionalSubject, o as useSubject };