@yielded/auth-persistence 0.1.0-beta.22 → 0.1.0-beta.23

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 (48) hide show
  1. package/dist/Adapter.d.mts +4 -2
  2. package/dist/Adapter.mjs +3 -1
  3. package/dist/internal/authority-snapshot.mjs +53 -0
  4. package/dist/internal/authority-snapshot.mjs.map +1 -0
  5. package/dist/internal/configuration.d.mts +1 -1
  6. package/dist/internal/configuration.mjs.map +1 -1
  7. package/dist/internal/models/password-model.d.mts +3 -53
  8. package/dist/internal/models/password-model.mjs +0 -2
  9. package/dist/internal/models/password-model.mjs.map +1 -1
  10. package/dist/internal/native-sql-table.d.mts +30 -0
  11. package/dist/internal/native-sql-table.mjs +104 -0
  12. package/dist/internal/native-sql-table.mjs.map +1 -0
  13. package/dist/internal/passkey/target.d.mts +1 -1
  14. package/dist/internal/passkeys.mjs +1 -1
  15. package/dist/internal/password-attempts.d.mts +12 -0
  16. package/dist/internal/password-attempts.mjs +197 -0
  17. package/dist/internal/password-attempts.mjs.map +1 -0
  18. package/dist/internal/password-kernel.d.mts +14 -11
  19. package/dist/internal/password-kernel.mjs +39 -202
  20. package/dist/internal/password-kernel.mjs.map +1 -1
  21. package/dist/internal/persistence.d.mts +2 -0
  22. package/dist/internal/persistence.mjs +4 -4
  23. package/dist/internal/persistence.mjs.map +1 -1
  24. package/dist/internal/session-kernel.d.mts +3 -1
  25. package/dist/internal/session-kernel.mjs +22 -12
  26. package/dist/internal/session-kernel.mjs.map +1 -1
  27. package/dist/internal/sql-persistence.mjs +3 -1
  28. package/dist/internal/sql-persistence.mjs.map +1 -1
  29. package/dist/internal/storage-mapping.mjs +1 -30
  30. package/dist/internal/storage-mapping.mjs.map +1 -1
  31. package/dist/internal/storage-tables.d.mts +0 -43
  32. package/dist/internal/storage-tables.mjs +0 -26
  33. package/dist/internal/storage-tables.mjs.map +1 -1
  34. package/dist/internal/transaction-execution-kernel.d.mts +1 -1
  35. package/dist/internal/transaction-kernel.d.mts +1 -1
  36. package/package.json +1 -1
  37. package/src/Adapter.ts +9 -4
  38. package/src/internal/authority-snapshot.ts +91 -0
  39. package/src/internal/configuration.ts +1 -6
  40. package/src/internal/models/password-model.ts +0 -64
  41. package/src/internal/native-sql-table.ts +164 -0
  42. package/src/internal/password-attempts.ts +466 -0
  43. package/src/internal/password-kernel.ts +102 -623
  44. package/src/internal/persistence.ts +5 -10
  45. package/src/internal/session-kernel.ts +89 -27
  46. package/src/internal/sql-persistence.ts +2 -0
  47. package/src/internal/storage-mapping.ts +0 -29
  48. package/src/internal/storage-tables.ts +0 -15
@@ -0,0 +1,466 @@
1
+ import {
2
+ coordinateCommit,
3
+ CurrentCommitJournal,
4
+ hasCommitScope,
5
+ LifecycleHooks,
6
+ } from "@yielded/auth/Hooks";
7
+ import type { LoginIdentifier } from "@yielded/auth/Identity";
8
+ import {
9
+ type PasswordPersistence,
10
+ PasswordUnavailable,
11
+ snapshotPasswordCredential,
12
+ type PasswordCredentialSnapshot,
13
+ type PreparePasswordCommit,
14
+ type PasswordAttemptAdmission,
15
+ } from "@yielded/auth/Password";
16
+ import { reportPersistenceFailure } from "@yielded/auth/Persistence";
17
+ import { DateTime, Effect, Option, Redacted, Schema } from "effect";
18
+
19
+ import type { PersistenceMappingError } from "./mapping-error";
20
+ import type { AnyPasswordPersistenceMapping } from "./models/password-model";
21
+ import { sqlMapping, type SqlTable } from "./native-sql-table";
22
+ import type { PasswordSqlConfiguration } from "./password-kernel";
23
+ import { PasswordPreparedPostconditions } from "./PasswordPreparedPostconditions";
24
+ import { NativeDatabase } from "./transaction-kernel";
25
+
26
+ type Row = Readonly<Record<string, unknown>>;
27
+ type Mapping = AnyPasswordPersistenceMapping;
28
+
29
+ const unavailable = () => PasswordUnavailable.make({});
30
+ const nowMillis = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis));
31
+
32
+ const failure = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
33
+ reportPersistenceFailure(effect, Schema.is(PasswordUnavailable)).pipe(
34
+ Effect.mapError(unavailable),
35
+ );
36
+
37
+ export const samePasswordCredentialSnapshot = (
38
+ a: PasswordCredentialSnapshot,
39
+ b: PasswordCredentialSnapshot,
40
+ ) =>
41
+ a.moduleId === b.moduleId &&
42
+ a.revision.subjectId === b.revision.subjectId &&
43
+ a.revision.securityRevision === b.revision.securityRevision &&
44
+ a.revision.credentials.length === b.revision.credentials.length &&
45
+ new Set(a.revision.credentials.map((entry) => entry.credentialId)).size ===
46
+ a.revision.credentials.length &&
47
+ new Set(b.revision.credentials.map((entry) => entry.credentialId)).size ===
48
+ b.revision.credentials.length &&
49
+ a.revision.credentials.every((entry) =>
50
+ b.revision.credentials.some(
51
+ (other) => entry.credentialId === other.credentialId && entry.revision === other.revision,
52
+ ),
53
+ ) &&
54
+ a.credentialId === b.credentialId &&
55
+ a.credentialRevision === b.credentialRevision &&
56
+ a.verifierVersion === b.verifierVersion &&
57
+ Redacted.value(a.verifier) === Redacted.value(b.verifier) &&
58
+ a.normalization === b.normalization &&
59
+ a.identifier.namespace === b.identifier.namespace &&
60
+ a.identifier.value === b.identifier.value &&
61
+ a.identifierBindingRevision === b.identifierBindingRevision &&
62
+ a.identifierVerifiedAtMillis === b.identifierVerifiedAtMillis;
63
+
64
+ /** The interactive attempt workflow uses the owning Effect SQL client directly.
65
+ * Table adapters supply representation only; admission and settlement policy live here.
66
+ */
67
+ export const makePasswordAttempts = Effect.fnUntraced(function* (
68
+ table: (table: object) => SqlTable,
69
+ mapping: Mapping,
70
+ options: PasswordSqlConfiguration,
71
+ ): Effect.fn.Return<
72
+ Pick<PasswordPersistence["Service"], "prepareAttempt" | "settleAttempt">,
73
+ PasswordUnavailable,
74
+ LifecycleHooks | NativeDatabase
75
+ > {
76
+ const sql = (yield* NativeDatabase).$client.withoutTransforms();
77
+ const hooks = yield* LifecycleHooks;
78
+ const parent = yield* Effect.serviceOption(CurrentCommitJournal);
79
+
80
+ const [s, i, c, a] = yield* sqlMapping(
81
+ () =>
82
+ [
83
+ table(mapping.subject.table),
84
+ table(mapping.identifier.table),
85
+ table(mapping.credential.table),
86
+ table(mapping.attempt.table),
87
+ ] as const,
88
+ ).pipe(failure);
89
+
90
+ const lock = options.locking ? sql.literal(" FOR UPDATE") : sql.literal("");
91
+
92
+ const standalone = Effect.gen(function* () {
93
+ if (options.coordinated) {
94
+ const current = yield* Effect.serviceOption(CurrentCommitJournal);
95
+
96
+ if (Option.isNone(parent) || Option.isNone(current) || parent.value !== current.value)
97
+ return yield* unavailable();
98
+ } else {
99
+ if (yield* hasCommitScope) return yield* unavailable();
100
+ yield* options.standaloneGuard;
101
+ }
102
+ });
103
+
104
+ const owned = <A, E, R>(work: Effect.Effect<A, E, R>) =>
105
+ standalone.pipe(
106
+ Effect.andThen(coordinateCommit(() => sql.withTransaction(work), { mode: options.mode })),
107
+ Effect.map((result) => result.value),
108
+ Effect.provideService(LifecycleHooks, hooks),
109
+ failure,
110
+ );
111
+
112
+ const allocate = <A>(
113
+ asynchronous: Effect.Effect<A, PersistenceMappingError> | undefined,
114
+ sync: (() => A) | undefined,
115
+ ) => {
116
+ if (options.mode !== "synchronous" && asynchronous !== undefined) return asynchronous;
117
+
118
+ return sync === undefined
119
+ ? Effect.fail(unavailable())
120
+ : Effect.try({ try: sync, catch: unavailable });
121
+ };
122
+
123
+ // Bind each native ID through its own column codec. Physical encodings of the
124
+ // same application ID need not match across subject, identifier and password.
125
+ const snapshot = Effect.fnUntraced(function* (
126
+ nativeId: unknown,
127
+ moduleId: string,
128
+ identifier: LoginIdentifier,
129
+ locking: boolean,
130
+ attemptId?: string,
131
+ ) {
132
+ const subjectTable = s.as("password_subject");
133
+ const identifierTable = i.as("password_identifier");
134
+ const credentialTable = c.as("password_credential");
135
+ const attemptTable = a.as("password_attempt");
136
+
137
+ const rows = yield* sqlMapping(
138
+ () =>
139
+ sql<Row>`select ${subjectTable.fields("subject_")}, ${identifierTable.fields("identifier_")}, ${credentialTable.fields("credential_")}
140
+ ${attemptId === undefined ? sql.literal("") : sql`, ${attemptTable.fields("attempt_")}`}
141
+ from ${subjectTable.name}
142
+ inner join ${identifierTable.name} on ${identifierTable.column(mapping.identifier.namespace)} = ${identifierTable.value(mapping.identifier.namespace, identifier.namespace)}
143
+ and ${identifierTable.column(mapping.identifier.value)} = ${identifierTable.value(mapping.identifier.value, identifier.value)}
144
+ and ${identifierTable.column(mapping.identifier.subjectId)} = ${identifierTable.value(mapping.identifier.subjectId, nativeId)}
145
+ inner join ${credentialTable.name} on ${credentialTable.column(mapping.credential.moduleId)} = ${credentialTable.value(mapping.credential.moduleId, moduleId)}
146
+ and ${credentialTable.column(mapping.credential.subjectId)} = ${credentialTable.value(mapping.credential.subjectId, nativeId)}
147
+ ${
148
+ attemptId === undefined
149
+ ? sql.literal("")
150
+ : sql`inner join ${attemptTable.name} on
151
+ ${attemptTable.column(mapping.attempt.moduleId)} = ${attemptTable.value(mapping.attempt.moduleId, moduleId)}
152
+ and ${attemptTable.column(mapping.attempt.attemptId)} = ${attemptTable.value(mapping.attempt.attemptId, attemptId)}`
153
+ }
154
+ where ${subjectTable.column(mapping.subject.id)} = ${subjectTable.value(mapping.subject.id, nativeId)}
155
+ ${locking ? lock : sql.literal("")}`,
156
+ ).pipe(Effect.flatten);
157
+
158
+ if (rows.length !== 1) return undefined;
159
+
160
+ const [subject, identifierRow, credential] = yield* sqlMapping(
161
+ () =>
162
+ [
163
+ s.decode(rows[0]!, "subject_"),
164
+ i.decode(rows[0]!, "identifier_"),
165
+ c.decode(rows[0]!, "credential_"),
166
+ ] as const,
167
+ );
168
+
169
+ if (
170
+ yield* sqlMapping(
171
+ () =>
172
+ !mapping.subject.isActiveStatus(subject[mapping.subject.status]) ||
173
+ !mapping.identifier.isCurrent(identifierRow) ||
174
+ !mapping.subjectId.equals(nativeId, identifierRow[mapping.identifier.subjectId]) ||
175
+ !mapping.subjectId.equals(nativeId, subject[mapping.subject.id]) ||
176
+ !mapping.subjectId.equals(nativeId, credential[mapping.credential.subjectId]),
177
+ )
178
+ )
179
+ return undefined;
180
+
181
+ const captured = yield* sqlMapping(() =>
182
+ mapping.credential.decode({ moduleId, subject, identifier: identifierRow, credential }),
183
+ ).pipe(Effect.flatten, Effect.flatMap(snapshotPasswordCredential));
184
+
185
+ return {
186
+ nativeId,
187
+ captured,
188
+ attempt:
189
+ attemptId === undefined
190
+ ? undefined
191
+ : yield* sqlMapping(() => a.decode(rows[0]!, "attempt_")),
192
+ };
193
+ });
194
+
195
+ const attemptRow = Effect.fnUntraced(function* (
196
+ moduleId: string,
197
+ attemptId: string,
198
+ locking: boolean,
199
+ ) {
200
+ const rows = yield* sqlMapping(
201
+ () => sql<Row>`select ${a.fields("a_")} from ${a.name}
202
+ where ${a.column(mapping.attempt.moduleId)} = ${a.value(mapping.attempt.moduleId, moduleId)}
203
+ and ${a.column(mapping.attempt.attemptId)} = ${a.value(mapping.attempt.attemptId, attemptId)}
204
+ ${locking ? lock : sql.literal("")}`,
205
+ ).pipe(Effect.flatten);
206
+
207
+ return rows.length === 1 ? yield* sqlMapping(() => a.decode(rows[0]!, "a_")) : undefined;
208
+ });
209
+
210
+ const attemptMatches = (row: Row, candidate: PasswordCredentialSnapshot, nativeId: unknown) =>
211
+ row[mapping.attempt.moduleId] === candidate.moduleId &&
212
+ row[mapping.attempt.identifierNamespace] === candidate.identifier.namespace &&
213
+ row[mapping.attempt.identifierValue] === candidate.identifier.value &&
214
+ mapping.subjectId.equals(row[mapping.attempt.subjectId], nativeId) &&
215
+ row[mapping.attempt.credentialId] === candidate.credentialId &&
216
+ row[mapping.attempt.securityRevision] === candidate.revision.securityRevision &&
217
+ row[mapping.attempt.credentialRevision] === candidate.credentialRevision &&
218
+ row[mapping.attempt.verifierVersion] === candidate.verifierVersion &&
219
+ row[mapping.attempt.identifierBindingRevision] === candidate.identifierBindingRevision;
220
+
221
+ const service: Pick<PasswordPersistence["Service"], "prepareAttempt" | "settleAttempt"> = {
222
+ // The remaining operations are composed by the persistence owner.
223
+ prepareAttempt: (uncaptured) =>
224
+ Effect.gen(function* () {
225
+ const input = Object.freeze({
226
+ ...uncaptured,
227
+ identifier: Object.freeze({ ...uncaptured.identifier }),
228
+ });
229
+
230
+ yield* standalone;
231
+
232
+ const identifiers = yield* sqlMapping(
233
+ () => sql<Row>`select ${i.fields("i_")} from ${i.name}
234
+ where ${i.column(mapping.identifier.namespace)} = ${i.value(mapping.identifier.namespace, input.identifier.namespace)}
235
+ and ${i.column(mapping.identifier.value)} = ${i.value(mapping.identifier.value, input.identifier.value)}`,
236
+ ).pipe(Effect.flatten);
237
+
238
+ const identifier =
239
+ identifiers.length === 1
240
+ ? yield* sqlMapping(() => i.decode(identifiers[0]!, "i_"))
241
+ : undefined;
242
+
243
+ const resolved =
244
+ identifier === undefined ||
245
+ !(yield* sqlMapping(() => mapping.identifier.isCurrent(identifier)))
246
+ ? undefined
247
+ : yield* snapshot(
248
+ identifier[mapping.identifier.subjectId],
249
+ input.moduleId,
250
+ input.identifier,
251
+ false,
252
+ );
253
+
254
+ const candidate =
255
+ resolved !== undefined &&
256
+ (input.subjectId === undefined ||
257
+ resolved.captured.revision.subjectId === input.subjectId)
258
+ ? resolved
259
+ : undefined;
260
+
261
+ const attemptId = yield* allocate(mapping.allocateAttemptId, mapping.allocateAttemptIdSync);
262
+ let used = false;
263
+
264
+ return Object.freeze({
265
+ ...(candidate === undefined ? {} : { credential: candidate.captured }),
266
+ admit: <A>(prepare: PreparePasswordCommit<PasswordAttemptAdmission, A>) =>
267
+ Effect.suspend(() => {
268
+ if (used) return Effect.fail(unavailable());
269
+ used = true;
270
+
271
+ return owned(
272
+ Effect.gen(function* () {
273
+ const journal = yield* CurrentCommitJournal;
274
+ const now = yield* nowMillis;
275
+ const captured = candidate?.captured;
276
+
277
+ // This receipt records what was inspected; it grants no authority.
278
+ // Settlement checks the current database against this exact snapshot.
279
+ yield* sqlMapping(
280
+ () =>
281
+ sql`${a.insert(
282
+ mapping.attempt.encodeInsert(
283
+ {
284
+ moduleId: input.moduleId,
285
+ action: input.action,
286
+ attemptId,
287
+ identifier: input.identifier,
288
+ ...(captured === undefined
289
+ ? {}
290
+ : {
291
+ subjectId: captured.revision.subjectId,
292
+ credentialId: captured.credentialId,
293
+ securityRevision: captured.revision.securityRevision,
294
+ credentialRevision: captured.credentialRevision,
295
+ verifierVersion: captured.verifierVersion,
296
+ identifierBindingRevision: captured.identifierBindingRevision,
297
+ }),
298
+ admittedAtMillis: now,
299
+ deadlineMillis: now + input.attemptLifetimeMillis,
300
+ retentionUntilMillis: now + input.attemptLifetimeMillis,
301
+ },
302
+ {
303
+ ...(candidate === undefined
304
+ ? {}
305
+ : { nativeSubjectId: candidate.nativeId }),
306
+ state: "pending",
307
+ },
308
+ ),
309
+ )}`,
310
+ ).pipe(Effect.flatten);
311
+
312
+ return prepare(
313
+ {
314
+ _tag: "Admitted",
315
+ attemptId,
316
+ ...(captured === undefined ? {} : { credential: captured }),
317
+ },
318
+ journal,
319
+ );
320
+ }),
321
+ );
322
+ }),
323
+ });
324
+ }).pipe(failure),
325
+ settleAttempt: (uncaptured, prepare) =>
326
+ owned(
327
+ Effect.gen(function* () {
328
+ const input = Object.freeze({
329
+ ...uncaptured,
330
+ ...(uncaptured.rehash === undefined
331
+ ? {}
332
+ : { rehash: Object.freeze({ ...uncaptured.rehash }) }),
333
+ });
334
+
335
+ const journal = yield* CurrentCommitJournal;
336
+
337
+ const captured =
338
+ input.captured === undefined
339
+ ? undefined
340
+ : yield* snapshotPasswordCredential(input.captured);
341
+
342
+ const discovered = yield* attemptRow(input.moduleId, input.attemptId, false);
343
+
344
+ if (discovered === undefined || discovered[mapping.attempt.state] !== "pending")
345
+ return prepare("rejected", journal);
346
+
347
+ // Lock the subject before the attempt: a coordinated application
348
+ // transaction may already hold the subject lock. Without row locks
349
+ // the discovery read is already serialized, so it is not repeated.
350
+ const current =
351
+ input.outcome !== "verified" || captured === undefined
352
+ ? undefined
353
+ : yield* snapshot(
354
+ discovered[mapping.attempt.subjectId],
355
+ input.moduleId,
356
+ captured.identifier,
357
+ true,
358
+ );
359
+
360
+ const row = options.locking
361
+ ? yield* attemptRow(input.moduleId, input.attemptId, true)
362
+ : discovered;
363
+
364
+ if (row === undefined || row[mapping.attempt.state] !== "pending")
365
+ return prepare("rejected", journal);
366
+ const now = yield* nowMillis;
367
+ const deadline = yield* mapping.decodeInstant(row[mapping.attempt.deadline]);
368
+
369
+ const verified =
370
+ captured !== undefined &&
371
+ current !== undefined &&
372
+ captured.moduleId === input.moduleId &&
373
+ deadline > now &&
374
+ attemptMatches(row, captured, current.nativeId) &&
375
+ samePasswordCredentialSnapshot(current.captured, captured);
376
+
377
+ const decision = verified ? "verified" : "rejected";
378
+ const receipt = prepare(decision, journal);
379
+
380
+ yield* sqlMapping(
381
+ () => sql`${a.update({ [mapping.attempt.state]: decision })}
382
+ where ${a.column(mapping.attempt.moduleId)} = ${a.value(mapping.attempt.moduleId, input.moduleId)}
383
+ and ${a.column(mapping.attempt.attemptId)} = ${a.value(mapping.attempt.attemptId, input.attemptId)}
384
+ and ${a.column(mapping.attempt.state)} = ${a.value(mapping.attempt.state, "pending")}`,
385
+ ).pipe(Effect.flatten);
386
+
387
+ if (verified && current !== undefined && captured !== undefined) {
388
+ let expected = current.captured;
389
+
390
+ if (input.rehash !== undefined) {
391
+ if (
392
+ input.rehash.expectedVersion !== expected.verifierVersion ||
393
+ Redacted.value(input.rehash.expectedVerifier) !== Redacted.value(expected.verifier)
394
+ )
395
+ return yield* unavailable();
396
+
397
+ const version = yield* allocate(
398
+ mapping.allocateRevision,
399
+ mapping.allocateRevisionSync,
400
+ );
401
+
402
+ const rehash = input.rehash;
403
+
404
+ yield* sqlMapping(
405
+ () => sql`${c.update(mapping.credential.encodeVerifier(rehash.nextVerifier, version))}
406
+ where ${c.column(mapping.credential.moduleId)} = ${c.value(mapping.credential.moduleId, input.moduleId)}
407
+ and ${c.column(mapping.credential.subjectId)} = ${c.value(mapping.credential.subjectId, current.nativeId)}
408
+ and ${c.column(mapping.credential.credentialId)} = ${c.value(mapping.credential.credentialId, captured.credentialId)}
409
+ and ${c.column(mapping.credential.credentialRevision)} = ${c.value(mapping.credential.credentialRevision, captured.credentialRevision)}
410
+ and ${c.column(mapping.credential.verifierVersion)} = ${c.value(mapping.credential.verifierVersion, rehash.expectedVersion)}
411
+ and ${c.column(mapping.credential.verifier)} = ${c.value(mapping.credential.verifier, Redacted.value(rehash.expectedVerifier))}`,
412
+ ).pipe(Effect.flatten);
413
+ expected = {
414
+ ...expected,
415
+ verifierVersion: version,
416
+ verifier: input.rehash.nextVerifier,
417
+ };
418
+ }
419
+
420
+ const settled = expected;
421
+
422
+ const applied = Effect.gen(function* () {
423
+ const final = yield* snapshot(
424
+ current.nativeId,
425
+ input.moduleId,
426
+ captured.identifier,
427
+ false,
428
+ input.attemptId,
429
+ );
430
+
431
+ return (
432
+ final !== undefined &&
433
+ samePasswordCredentialSnapshot(final.captured, settled) &&
434
+ final.attempt !== undefined &&
435
+ final.attempt[mapping.attempt.state] === "verified" &&
436
+ attemptMatches(final.attempt, captured, current.nativeId) &&
437
+ (yield* mapping.decodeInstant(final.attempt[mapping.attempt.deadline])) ===
438
+ deadline &&
439
+ deadline > (yield* nowMillis)
440
+ );
441
+ }).pipe(failure);
442
+
443
+ if (!(yield* applied)) return yield* unavailable();
444
+
445
+ // A coordinator that accepts postconditions repeats this check after
446
+ // application work, inside the same transaction.
447
+ const postconditions = yield* Effect.serviceOption(PasswordPreparedPostconditions);
448
+
449
+ if (
450
+ Option.isSome(postconditions) &&
451
+ !postconditions.value.register(
452
+ Effect.flatMap(applied, (holds) =>
453
+ holds ? Effect.void : Effect.fail(unavailable()),
454
+ ),
455
+ )
456
+ )
457
+ return yield* unavailable();
458
+ }
459
+
460
+ return receipt;
461
+ }),
462
+ ),
463
+ };
464
+
465
+ return service;
466
+ });