@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
|
@@ -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 { }
|
package/dist/strategy.js
ADDED
package/dist/tokens.d.ts
ADDED
|
@@ -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 };
|