@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.
- package/dist/Adapter.d.mts +4 -2
- package/dist/Adapter.mjs +3 -1
- package/dist/internal/authority-snapshot.mjs +53 -0
- package/dist/internal/authority-snapshot.mjs.map +1 -0
- package/dist/internal/configuration.d.mts +1 -1
- package/dist/internal/configuration.mjs.map +1 -1
- package/dist/internal/models/password-model.d.mts +3 -53
- package/dist/internal/models/password-model.mjs +0 -2
- package/dist/internal/models/password-model.mjs.map +1 -1
- package/dist/internal/native-sql-table.d.mts +30 -0
- package/dist/internal/native-sql-table.mjs +104 -0
- package/dist/internal/native-sql-table.mjs.map +1 -0
- package/dist/internal/passkey/target.d.mts +1 -1
- package/dist/internal/passkeys.mjs +1 -1
- package/dist/internal/password-attempts.d.mts +12 -0
- package/dist/internal/password-attempts.mjs +197 -0
- package/dist/internal/password-attempts.mjs.map +1 -0
- package/dist/internal/password-kernel.d.mts +14 -11
- package/dist/internal/password-kernel.mjs +39 -202
- package/dist/internal/password-kernel.mjs.map +1 -1
- package/dist/internal/persistence.d.mts +2 -0
- package/dist/internal/persistence.mjs +4 -4
- package/dist/internal/persistence.mjs.map +1 -1
- package/dist/internal/session-kernel.d.mts +3 -1
- package/dist/internal/session-kernel.mjs +22 -12
- package/dist/internal/session-kernel.mjs.map +1 -1
- package/dist/internal/sql-persistence.mjs +3 -1
- package/dist/internal/sql-persistence.mjs.map +1 -1
- package/dist/internal/storage-mapping.mjs +1 -30
- package/dist/internal/storage-mapping.mjs.map +1 -1
- package/dist/internal/storage-tables.d.mts +0 -43
- package/dist/internal/storage-tables.mjs +0 -26
- package/dist/internal/storage-tables.mjs.map +1 -1
- package/dist/internal/transaction-execution-kernel.d.mts +1 -1
- package/dist/internal/transaction-kernel.d.mts +1 -1
- package/package.json +1 -1
- package/src/Adapter.ts +9 -4
- package/src/internal/authority-snapshot.ts +91 -0
- package/src/internal/configuration.ts +1 -6
- package/src/internal/models/password-model.ts +0 -64
- package/src/internal/native-sql-table.ts +164 -0
- package/src/internal/password-attempts.ts +466 -0
- package/src/internal/password-kernel.ts +102 -623
- package/src/internal/persistence.ts +5 -10
- package/src/internal/session-kernel.ts +89 -27
- package/src/internal/sql-persistence.ts +2 -0
- package/src/internal/storage-mapping.ts +0 -29
- 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
|
+
});
|