@yielded/auth-persistence 0.1.0-beta.28 → 0.1.0-beta.30
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/README.md +3 -0
- package/dist/Testing.d.mts +44 -0
- package/dist/Testing.mjs +310 -0
- package/dist/Testing.mjs.map +1 -0
- package/dist/internal/models/oauth-model.d.mts +10 -1
- package/dist/internal/models/oauth-model.mjs.map +1 -1
- package/dist/internal/oauth/native-accounts.mjs +42 -1
- package/dist/internal/oauth/native-accounts.mjs.map +1 -1
- package/dist/internal/oauth/native-state.d.mts +1 -0
- package/dist/internal/oauth/native-state.mjs +2 -2
- package/dist/internal/oauth/native-state.mjs.map +1 -1
- package/dist/internal/sql-change.mjs +11 -1
- package/dist/internal/sql-change.mjs.map +1 -1
- package/package.json +1 -1
- package/src/Testing.ts +652 -0
- package/src/internal/models/oauth-model.ts +10 -0
- package/src/internal/oauth/native-accounts.ts +72 -1
- package/src/internal/oauth/native-state.ts +6 -2
- package/src/internal/sql-change.ts +14 -1
package/src/Testing.ts
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/** Explicit, runtime-independent test support. Never imported by production entrypoints. */
|
|
2
|
+
import {
|
|
3
|
+
coordinateCommit,
|
|
4
|
+
hasCommitScope,
|
|
5
|
+
LifecycleHooks,
|
|
6
|
+
type CommitJournal,
|
|
7
|
+
type PreparedCommit,
|
|
8
|
+
} from "@yielded/auth/Hooks";
|
|
9
|
+
import {
|
|
10
|
+
EncodedPasswordHash,
|
|
11
|
+
PasswordCredentialSnapshot,
|
|
12
|
+
PasswordHashing,
|
|
13
|
+
PasswordPersistence,
|
|
14
|
+
PasswordUnavailable,
|
|
15
|
+
snapshotPasswordCredential,
|
|
16
|
+
snapshotPasswordRequirement,
|
|
17
|
+
snapshotPasswordRevision,
|
|
18
|
+
} from "@yielded/auth/Password";
|
|
19
|
+
import { hooksLayer } from "@yielded/auth/Persistence";
|
|
20
|
+
import { Email, SubjectId, TokenDigest } from "@yielded/auth/Schema";
|
|
21
|
+
import {
|
|
22
|
+
type AuthenticationRequirement,
|
|
23
|
+
assessAuthentication,
|
|
24
|
+
AuthenticationAuthority,
|
|
25
|
+
type AuthenticationRevision,
|
|
26
|
+
PendingAuthenticationInvalid,
|
|
27
|
+
SecurityRevision,
|
|
28
|
+
SessionAuthenticationProvenance,
|
|
29
|
+
SessionConflict,
|
|
30
|
+
SessionCredentialVersion,
|
|
31
|
+
SessionId,
|
|
32
|
+
SessionInvalid,
|
|
33
|
+
SessionMetadata,
|
|
34
|
+
SessionUnavailable,
|
|
35
|
+
snapshotAuthenticationEvidence,
|
|
36
|
+
snapshotSessionAuthenticationProvenance,
|
|
37
|
+
StaleAuthentication,
|
|
38
|
+
type StatefulSessionPersistence,
|
|
39
|
+
} from "@yielded/auth/Sessions";
|
|
40
|
+
import * as KdfAdmission from "@yielded/crypto/KdfAdmission";
|
|
41
|
+
import * as Portable from "@yielded/crypto/Portable";
|
|
42
|
+
import { layerCryptoWeb } from "@yielded/crypto/WebCrypto";
|
|
43
|
+
import {
|
|
44
|
+
Clock,
|
|
45
|
+
Context,
|
|
46
|
+
Crypto,
|
|
47
|
+
DateTime,
|
|
48
|
+
Effect,
|
|
49
|
+
Layer,
|
|
50
|
+
Option,
|
|
51
|
+
Random,
|
|
52
|
+
Redacted,
|
|
53
|
+
Schema,
|
|
54
|
+
Semaphore,
|
|
55
|
+
} from "effect";
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
PersistenceConfigurationError,
|
|
59
|
+
type ClaimsCodec,
|
|
60
|
+
type Definition,
|
|
61
|
+
} from "./internal/configuration";
|
|
62
|
+
|
|
63
|
+
/** One existing subject and one byte-preserving password. Seeding does not verify
|
|
64
|
+
* an email or exercise registration/screening. Passwords are hashed on acquisition
|
|
65
|
+
* by the supplied PasswordHashing service; they are never stored as plaintext. */
|
|
66
|
+
export const PasswordSubject = Schema.Struct({
|
|
67
|
+
subjectId: SubjectId,
|
|
68
|
+
email: Email,
|
|
69
|
+
password: Schema.Redacted(Schema.String),
|
|
70
|
+
active: Schema.optionalKey(Schema.Boolean),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export interface Options {
|
|
74
|
+
readonly subjects: ReadonlyArray<typeof PasswordSubject.Encoded>;
|
|
75
|
+
/** Application policy; no permissive test default. */
|
|
76
|
+
readonly requirement: AuthenticationRequirement;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const configurationError = (reason: string) => PersistenceConfigurationError.make({ reason });
|
|
80
|
+
const unavailable = () => SessionUnavailable.make({});
|
|
81
|
+
|
|
82
|
+
export interface ServicesOptions {
|
|
83
|
+
/** Reproducible test entropy. Omit to use the host's secure randomness. */
|
|
84
|
+
readonly seed?: string | number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Portable password hashing and Effect Crypto for tests. Requires global WebCrypto
|
|
88
|
+
* at acquisition; uses the default password cost and one shared KDF admission owner.
|
|
89
|
+
* A seed restarts its private random sequence on each acquisition; it never changes
|
|
90
|
+
* the caller's Random or Clock services. Seeded credentials are only suitable for tests.
|
|
91
|
+
* Provide this after composing Auth with Testing.layer and application claims.
|
|
92
|
+
*/
|
|
93
|
+
export const services = (options: ServicesOptions = {}) => {
|
|
94
|
+
const { seed } = options;
|
|
95
|
+
|
|
96
|
+
return Layer.unwrap(
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const subtle = globalThis.crypto?.subtle;
|
|
99
|
+
|
|
100
|
+
if (subtle === undefined)
|
|
101
|
+
return yield* configurationError("Testing.services requires global WebCrypto");
|
|
102
|
+
|
|
103
|
+
const entropy =
|
|
104
|
+
seed === undefined
|
|
105
|
+
? layerCryptoWeb
|
|
106
|
+
: Layer.effect(
|
|
107
|
+
Crypto.Crypto,
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const native = yield* Crypto.Crypto;
|
|
110
|
+
const random = yield* Random.Random.pipe(Random.withSeed(seed));
|
|
111
|
+
|
|
112
|
+
return Crypto.make({
|
|
113
|
+
randomBytes: (size) =>
|
|
114
|
+
Uint8Array.from({ length: size }, () => random.nextIntUnsafe() & 255),
|
|
115
|
+
digest: (algorithm, data) => native.digest(algorithm, data),
|
|
116
|
+
});
|
|
117
|
+
}),
|
|
118
|
+
).pipe(Layer.provide(layerCryptoWeb));
|
|
119
|
+
|
|
120
|
+
const backend = Portable.layer(subtle).pipe(Layer.provideMerge(KdfAdmission.layer()));
|
|
121
|
+
|
|
122
|
+
return PasswordHashing.layer().pipe(Layer.provide(backend), Layer.provideMerge(entropy));
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Fresh scoped memory for exactly one Password.make() sign-in strategy and
|
|
128
|
+
* stateful sessions: issue, verify, renew, list, revoke and sign out. No SQL,
|
|
129
|
+
* filesystem, native module, platform detection or implicit cryptography.
|
|
130
|
+
* Uses the Effect Clock captured at acquisition, including TestClock.
|
|
131
|
+
* Claims, hashing, Crypto, session policy and private delivery remain application
|
|
132
|
+
* responsibilities. Other strategies, password management and session modes fail
|
|
133
|
+
* at acquisition; pending authentication, handoff and ambient commits are unsupported.
|
|
134
|
+
* Separate acquisitions own separate state, cleared on scope close. Reusing this
|
|
135
|
+
* Layer within one build shares state. Never use for production authority or to
|
|
136
|
+
* verify a production adapter's concurrency, durability or crash recovery.
|
|
137
|
+
*/
|
|
138
|
+
export const layer = <C extends ClaimsCodec, const Id extends string>(
|
|
139
|
+
auth: Definition<C, Id>,
|
|
140
|
+
options: Options,
|
|
141
|
+
): Layer.Layer<
|
|
142
|
+
| AuthenticationAuthority
|
|
143
|
+
| PasswordPersistence
|
|
144
|
+
| Context.Service.Identifier<Definition<C, Id>["sessions"]["StatefulSessionPersistence"]>
|
|
145
|
+
| Context.Service.Identifier<Definition<C, Id>["sessions"]["SessionRepository"]>,
|
|
146
|
+
PersistenceConfigurationError,
|
|
147
|
+
PasswordHashing
|
|
148
|
+
> =>
|
|
149
|
+
Layer.effectContext(
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const strategies = Object.values(auth.strategies);
|
|
152
|
+
const feature = strategies[0]?.persistence;
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
auth.sessionMode !== "stateful" ||
|
|
156
|
+
strategies.length !== 1 ||
|
|
157
|
+
feature?.kind !== "password" ||
|
|
158
|
+
feature.management ||
|
|
159
|
+
feature.lifecycle
|
|
160
|
+
) {
|
|
161
|
+
return yield* configurationError(
|
|
162
|
+
"Testing.layer supports one sign-in-only Password.make() strategy and stateful sessions",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const seeds = yield* Schema.decodeEffect(Schema.Array(PasswordSubject))(
|
|
167
|
+
options.subjects,
|
|
168
|
+
).pipe(Effect.mapError(() => configurationError("Invalid in-memory password subjects")));
|
|
169
|
+
|
|
170
|
+
const requirement = yield* snapshotPasswordRequirement(options.requirement).pipe(
|
|
171
|
+
Effect.mapError(() => configurationError("Invalid in-memory authentication requirement")),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const hasher = yield* PasswordHashing;
|
|
175
|
+
const clock = yield* Clock.Clock;
|
|
176
|
+
const hooks = yield* LifecycleHooks;
|
|
177
|
+
const mutex = yield* Semaphore.make(1);
|
|
178
|
+
const claims: Schema.Codec<C["Type"], C["Encoded"]> = auth.claims;
|
|
179
|
+
|
|
180
|
+
const Session = Schema.Struct({
|
|
181
|
+
...SessionMetadata.fields,
|
|
182
|
+
claims,
|
|
183
|
+
digest: TokenDigest,
|
|
184
|
+
provenance: SessionAuthenticationProvenance,
|
|
185
|
+
credentialVersion: SessionCredentialVersion,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const Database = Schema.Struct({
|
|
189
|
+
sequence: Schema.Natural,
|
|
190
|
+
subjects: Schema.Array(
|
|
191
|
+
Schema.Struct({ active: Schema.Boolean, credential: PasswordCredentialSnapshot }),
|
|
192
|
+
),
|
|
193
|
+
sessions: Schema.Array(Session),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
type State = { -readonly [K in keyof typeof Database.Type]: (typeof Database.Type)[K] };
|
|
197
|
+
const codec = Schema.fromJsonString(Database);
|
|
198
|
+
const sessionCodec = Schema.fromJsonString(Session);
|
|
199
|
+
const credentialCodec = Schema.fromJsonString(PasswordCredentialSnapshot);
|
|
200
|
+
const initial: State = { sequence: 0, subjects: [], sessions: [] };
|
|
201
|
+
const ids = new Set<string>();
|
|
202
|
+
const emails = new Set<string>();
|
|
203
|
+
|
|
204
|
+
for (const seed of seeds) {
|
|
205
|
+
if (ids.has(seed.subjectId) || emails.has(seed.email))
|
|
206
|
+
return yield* configurationError("Duplicate in-memory subject ID or email");
|
|
207
|
+
ids.add(seed.subjectId);
|
|
208
|
+
emails.add(seed.email);
|
|
209
|
+
|
|
210
|
+
const verifier = yield* hasher
|
|
211
|
+
.hash(seed.password)
|
|
212
|
+
.pipe(Effect.mapError(() => configurationError("Cannot hash in-memory password seed")));
|
|
213
|
+
|
|
214
|
+
const credentialId = `password:${seed.subjectId}`;
|
|
215
|
+
|
|
216
|
+
const credential = yield* Schema.decodeEffect(PasswordCredentialSnapshot)({
|
|
217
|
+
moduleId: feature.moduleId,
|
|
218
|
+
revision: {
|
|
219
|
+
subjectId: seed.subjectId,
|
|
220
|
+
securityRevision: "1",
|
|
221
|
+
credentials: [{ credentialId, revision: "1" }],
|
|
222
|
+
},
|
|
223
|
+
credentialId,
|
|
224
|
+
credentialRevision: "1",
|
|
225
|
+
verifierVersion: "1",
|
|
226
|
+
verifier: Redacted.value(verifier),
|
|
227
|
+
normalization: "none",
|
|
228
|
+
identifier: { namespace: "email", value: seed.email },
|
|
229
|
+
identifierBindingRevision: "1",
|
|
230
|
+
}).pipe(Effect.mapError(() => configurationError("Invalid in-memory password seed")));
|
|
231
|
+
|
|
232
|
+
initial.subjects = [...initial.subjects, { active: seed.active ?? true, credential }];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Encoded snapshots detach private state from input, output and prepare callbacks.
|
|
236
|
+
let stored: string | undefined = yield* Schema.encodeEffect(codec)(initial).pipe(
|
|
237
|
+
Effect.mapError(() => configurationError("Cannot initialize in-memory storage")),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
yield* Effect.addFinalizer(() =>
|
|
241
|
+
Effect.sync(() => {
|
|
242
|
+
stored = undefined;
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const load = Effect.suspend(() =>
|
|
247
|
+
stored === undefined
|
|
248
|
+
? Effect.fail(unavailable())
|
|
249
|
+
: Schema.decodeEffect(codec)(stored).pipe(Effect.mapError(unavailable)),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const now = () => clock.currentTimeMillisUnsafe();
|
|
253
|
+
const nextId = (state: State) => `memory-${++state.sequence}`;
|
|
254
|
+
|
|
255
|
+
const subject = (state: State, id: SubjectId) =>
|
|
256
|
+
state.subjects.find((item) => item.active && item.credential.revision.subjectId === id);
|
|
257
|
+
|
|
258
|
+
const current = (state: State, revision: AuthenticationRevision) => {
|
|
259
|
+
const found = subject(state, revision.subjectId)?.credential;
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
found !== undefined &&
|
|
263
|
+
found.revision.securityRevision === revision.securityRevision &&
|
|
264
|
+
revision.credentials.length === 1 &&
|
|
265
|
+
revision.credentials[0]?.credentialId === found.credentialId &&
|
|
266
|
+
revision.credentials[0]?.revision === found.credentialRevision
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const live = (state: State, session: typeof Session.Type, time: number) =>
|
|
271
|
+
subject(state, session.subjectId)?.credential.revision.securityRevision ===
|
|
272
|
+
session.securityRevision &&
|
|
273
|
+
time >= DateTime.toEpochMillis(session.issuedAt) &&
|
|
274
|
+
time <
|
|
275
|
+
Math.min(
|
|
276
|
+
DateTime.toEpochMillis(session.expiresAt),
|
|
277
|
+
DateTime.toEpochMillis(session.absoluteExpiresAt),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const standalone = Effect.gen(function* () {
|
|
281
|
+
if (yield* hasCommitScope) return yield* unavailable();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const read = <A, E>(body: (state: State, time: number) => Effect.Effect<A, E>) =>
|
|
285
|
+
standalone.pipe(
|
|
286
|
+
Effect.andThen(mutex.withPermits(1)(Effect.flatMap(load, (state) => body(state, now())))),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// One local owner: failed/interrupted preparation never publishes its draft.
|
|
290
|
+
// Hooks dispatch outside the mutex, after publishing; failed delivery never rolls back.
|
|
291
|
+
const transaction = <A, E>(
|
|
292
|
+
body: (
|
|
293
|
+
state: State,
|
|
294
|
+
journal: CommitJournal,
|
|
295
|
+
time: number,
|
|
296
|
+
) => Effect.Effect<{ readonly receipt: PreparedCommit<A>; readonly deadline?: number }, E>,
|
|
297
|
+
) =>
|
|
298
|
+
standalone.pipe(
|
|
299
|
+
Effect.andThen(
|
|
300
|
+
coordinateCommit((journal) =>
|
|
301
|
+
mutex.withPermits(1)(
|
|
302
|
+
Effect.uninterruptibleMask((restore) =>
|
|
303
|
+
Effect.gen(function* () {
|
|
304
|
+
const { receipt, deadline, time, encoded } = yield* restore(
|
|
305
|
+
Effect.gen(function* () {
|
|
306
|
+
const state = yield* load;
|
|
307
|
+
const time = now();
|
|
308
|
+
const { receipt, deadline = Infinity } = yield* body(state, journal, time);
|
|
309
|
+
|
|
310
|
+
const encoded = yield* Schema.encodeEffect(codec)(state).pipe(
|
|
311
|
+
Effect.mapError(unavailable),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const status = yield* Effect.result(receipt.read);
|
|
315
|
+
|
|
316
|
+
if (status._tag !== "Failure" || status.failure._tag !== "CommitPending")
|
|
317
|
+
return yield* unavailable();
|
|
318
|
+
|
|
319
|
+
return { receipt, deadline, time, encoded };
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Publication and the owner's return form one uninterruptible commit.
|
|
324
|
+
yield* Effect.try({
|
|
325
|
+
try: () => {
|
|
326
|
+
const finalTime = now();
|
|
327
|
+
|
|
328
|
+
if (stored === undefined || finalTime < time || finalTime >= deadline)
|
|
329
|
+
throw unavailable();
|
|
330
|
+
stored = encoded;
|
|
331
|
+
},
|
|
332
|
+
catch: unavailable,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return receipt;
|
|
336
|
+
}),
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
Effect.map((result) => result.value),
|
|
342
|
+
Effect.catchTag("HookConfigurationError", unavailable),
|
|
343
|
+
Effect.provideService(LifecycleHooks, hooks),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const authority = AuthenticationAuthority.of({
|
|
347
|
+
capture: (id, anchors) =>
|
|
348
|
+
read((state) =>
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
const found = subject(state, id)?.credential;
|
|
351
|
+
|
|
352
|
+
if (
|
|
353
|
+
found === undefined ||
|
|
354
|
+
new Set(anchors).size !== anchors.length ||
|
|
355
|
+
anchors.some((anchor) => anchor !== found.credentialId)
|
|
356
|
+
)
|
|
357
|
+
return yield* StaleAuthentication.make({});
|
|
358
|
+
|
|
359
|
+
return { revision: snapshotPasswordRevision(found.revision), requirement };
|
|
360
|
+
}),
|
|
361
|
+
),
|
|
362
|
+
requirements: (evidence) =>
|
|
363
|
+
read((state) =>
|
|
364
|
+
Effect.gen(function* () {
|
|
365
|
+
const captured = yield* snapshotAuthenticationEvidence(evidence);
|
|
366
|
+
|
|
367
|
+
if (!current(state, captured.revision)) return yield* StaleAuthentication.make({});
|
|
368
|
+
|
|
369
|
+
return requirement;
|
|
370
|
+
}),
|
|
371
|
+
),
|
|
372
|
+
// Signed sessions are deliberately outside this helper's coverage.
|
|
373
|
+
approve: () => Effect.fail(unavailable()),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const sessions: StatefulSessionPersistence<C["Type"]> = {
|
|
377
|
+
establish: (input, prepare) =>
|
|
378
|
+
transaction((state, journal, time) =>
|
|
379
|
+
Effect.gen(function* () {
|
|
380
|
+
if (input.pending !== undefined) return yield* PendingAuthenticationInvalid.make({});
|
|
381
|
+
if (input.handoffSourceSessionId !== undefined) return yield* unavailable();
|
|
382
|
+
const evidence = yield* snapshotAuthenticationEvidence(input.evidence);
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
!current(state, evidence.revision) ||
|
|
386
|
+
evidence.proofs.length !== 1 ||
|
|
387
|
+
input.session.subjectId !== evidence.revision.subjectId ||
|
|
388
|
+
input.session.securityRevision !== evidence.revision.securityRevision
|
|
389
|
+
)
|
|
390
|
+
return yield* StaleAuthentication.make({});
|
|
391
|
+
|
|
392
|
+
const assessed = yield* assessAuthentication(
|
|
393
|
+
evidence,
|
|
394
|
+
requirement,
|
|
395
|
+
DateTime.makeUnsafe(time),
|
|
396
|
+
).pipe(Effect.catchTag("SessionConfigurationError", unavailable));
|
|
397
|
+
|
|
398
|
+
const expires = DateTime.toEpochMillis(input.session.expiresAt);
|
|
399
|
+
const absolute = DateTime.toEpochMillis(input.session.absoluteExpiresAt);
|
|
400
|
+
|
|
401
|
+
const deadline = Math.min(
|
|
402
|
+
expires,
|
|
403
|
+
DateTime.toEpochMillis(evidence.proofs[0].verifiedAt) +
|
|
404
|
+
requirement.maximumAgeMillis,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
if (!assessed.satisfied || time >= deadline || expires > absolute)
|
|
408
|
+
return yield* StaleAuthentication.make({});
|
|
409
|
+
if (state.sessions.some((row) => row.digest === input.session.digest))
|
|
410
|
+
return yield* SessionConflict.make({});
|
|
411
|
+
|
|
412
|
+
const row = {
|
|
413
|
+
...input.session,
|
|
414
|
+
sessionId: SessionId.make(nextId(state)),
|
|
415
|
+
issuedAt: DateTime.makeUnsafe(time),
|
|
416
|
+
assurance: assessed.assurance,
|
|
417
|
+
provenance: yield* snapshotSessionAuthenticationProvenance({ evidence }),
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const encoded = yield* Schema.encodeEffect(sessionCodec)(row).pipe(
|
|
421
|
+
Effect.mapError(unavailable),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const saved = yield* Schema.decodeEffect(sessionCodec)(encoded).pipe(
|
|
425
|
+
Effect.mapError(unavailable),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
state.sessions = [...state.sessions, saved];
|
|
429
|
+
|
|
430
|
+
return { receipt: prepare(row, journal), deadline };
|
|
431
|
+
}),
|
|
432
|
+
),
|
|
433
|
+
verify: ({ digest }) =>
|
|
434
|
+
read((state, time) => {
|
|
435
|
+
const row = state.sessions.find((session) => session.digest === digest);
|
|
436
|
+
|
|
437
|
+
return row !== undefined && live(state, row, time)
|
|
438
|
+
? Effect.succeed(row)
|
|
439
|
+
: Effect.fail(SessionInvalid.make({}));
|
|
440
|
+
}),
|
|
441
|
+
rotate: (input, prepare) =>
|
|
442
|
+
transaction((state, journal, time) =>
|
|
443
|
+
Effect.gen(function* () {
|
|
444
|
+
const row = state.sessions.find((session) => session.digest === input.record.digest);
|
|
445
|
+
const expires = DateTime.toEpochMillis(input.nextExpiresAt);
|
|
446
|
+
|
|
447
|
+
if (
|
|
448
|
+
row === undefined ||
|
|
449
|
+
!live(state, row, time) ||
|
|
450
|
+
input.nextCredentialVersion === row.credentialVersion ||
|
|
451
|
+
expires <= time ||
|
|
452
|
+
expires > DateTime.toEpochMillis(row.absoluteExpiresAt) ||
|
|
453
|
+
state.sessions.some((session) => session.digest === input.nextDigest)
|
|
454
|
+
)
|
|
455
|
+
return yield* SessionConflict.make({});
|
|
456
|
+
|
|
457
|
+
const expected = yield* Schema.encodeEffect(sessionCodec)(input.record).pipe(
|
|
458
|
+
Effect.mapError(unavailable),
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const actual = yield* Schema.encodeEffect(sessionCodec)(row).pipe(
|
|
462
|
+
Effect.mapError(unavailable),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (expected !== actual) return yield* SessionConflict.make({});
|
|
466
|
+
|
|
467
|
+
const next = {
|
|
468
|
+
...row,
|
|
469
|
+
digest: input.nextDigest,
|
|
470
|
+
credentialVersion: input.nextCredentialVersion,
|
|
471
|
+
issuedAt: DateTime.makeUnsafe(time),
|
|
472
|
+
expiresAt: input.nextExpiresAt,
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const encoded = yield* Schema.encodeEffect(sessionCodec)(next).pipe(
|
|
476
|
+
Effect.mapError(unavailable),
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
const saved = yield* Schema.decodeEffect(sessionCodec)(encoded).pipe(
|
|
480
|
+
Effect.mapError(unavailable),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
state.sessions = state.sessions.map((session) =>
|
|
484
|
+
session.digest === row.digest ? saved : session,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
receipt: prepare(next, journal),
|
|
489
|
+
deadline: Math.min(
|
|
490
|
+
expires,
|
|
491
|
+
DateTime.toEpochMillis(row.expiresAt),
|
|
492
|
+
DateTime.toEpochMillis(row.absoluteExpiresAt),
|
|
493
|
+
),
|
|
494
|
+
};
|
|
495
|
+
}),
|
|
496
|
+
),
|
|
497
|
+
revokeDigest: (digest, prepare) =>
|
|
498
|
+
transaction((state, journal) =>
|
|
499
|
+
Effect.sync(() => {
|
|
500
|
+
const found = state.sessions.some((row) => row.digest === digest);
|
|
501
|
+
|
|
502
|
+
state.sessions = state.sessions.filter((row) => row.digest !== digest);
|
|
503
|
+
|
|
504
|
+
return { receipt: prepare(found, journal) };
|
|
505
|
+
}),
|
|
506
|
+
),
|
|
507
|
+
revoke: (input, prepare) =>
|
|
508
|
+
transaction((state, journal) =>
|
|
509
|
+
Effect.sync(() => {
|
|
510
|
+
state.sessions = state.sessions.filter(
|
|
511
|
+
(row) => row.subjectId !== input.subjectId || row.sessionId !== input.sessionId,
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
return { receipt: prepare(undefined, journal) };
|
|
515
|
+
}),
|
|
516
|
+
),
|
|
517
|
+
revokeAll: (input, prepare) =>
|
|
518
|
+
transaction((state, journal) =>
|
|
519
|
+
Effect.gen(function* () {
|
|
520
|
+
const found = subject(state, input.subjectId);
|
|
521
|
+
|
|
522
|
+
if (found?.credential.revision.securityRevision !== input.expectedSecurityRevision)
|
|
523
|
+
return yield* StaleAuthentication.make({});
|
|
524
|
+
|
|
525
|
+
const credential = yield* snapshotPasswordCredential({
|
|
526
|
+
...found.credential,
|
|
527
|
+
revision: {
|
|
528
|
+
...found.credential.revision,
|
|
529
|
+
securityRevision: SecurityRevision.make(nextId(state)),
|
|
530
|
+
},
|
|
531
|
+
}).pipe(Effect.mapError(unavailable));
|
|
532
|
+
|
|
533
|
+
state.subjects = state.subjects.map((item) =>
|
|
534
|
+
item === found ? { ...item, credential } : item,
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
return { receipt: prepare(undefined, journal) };
|
|
538
|
+
}),
|
|
539
|
+
),
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const unsupported = () => Effect.fail(PasswordUnavailable.make({}));
|
|
543
|
+
|
|
544
|
+
const passwords = PasswordPersistence.of({
|
|
545
|
+
findCredential: (input) =>
|
|
546
|
+
read((state) =>
|
|
547
|
+
Effect.gen(function* () {
|
|
548
|
+
if (input.moduleId !== feature.moduleId) return yield* PasswordUnavailable.make({});
|
|
549
|
+
|
|
550
|
+
const found = state.subjects.find(
|
|
551
|
+
({ active, credential }) =>
|
|
552
|
+
active &&
|
|
553
|
+
credential.identifier.namespace === input.identifier.namespace &&
|
|
554
|
+
credential.identifier.value === input.identifier.value &&
|
|
555
|
+
(input.subjectId === undefined ||
|
|
556
|
+
credential.revision.subjectId === input.subjectId),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
return found === undefined
|
|
560
|
+
? Option.none()
|
|
561
|
+
: Option.some(yield* snapshotPasswordCredential(found.credential));
|
|
562
|
+
}),
|
|
563
|
+
).pipe(Effect.mapError(() => PasswordUnavailable.make({}))),
|
|
564
|
+
rehashIfCurrent: (input) =>
|
|
565
|
+
transaction((state, journal) =>
|
|
566
|
+
Effect.gen(function* () {
|
|
567
|
+
const captured = yield* snapshotPasswordCredential(input.credential);
|
|
568
|
+
|
|
569
|
+
const nextVerifier = yield* Schema.decodeEffect(EncodedPasswordHash)(
|
|
570
|
+
Redacted.value(input.nextVerifier),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const found = subject(state, captured.revision.subjectId);
|
|
574
|
+
|
|
575
|
+
if (
|
|
576
|
+
found !== undefined &&
|
|
577
|
+
captured.moduleId === feature.moduleId &&
|
|
578
|
+
(yield* Schema.encodeEffect(credentialCodec)(found.credential)) ===
|
|
579
|
+
(yield* Schema.encodeEffect(credentialCodec)(captured))
|
|
580
|
+
) {
|
|
581
|
+
const credential = yield* snapshotPasswordCredential({
|
|
582
|
+
...found.credential,
|
|
583
|
+
verifier: Redacted.make(nextVerifier),
|
|
584
|
+
verifierVersion: SecurityRevision.make(nextId(state)),
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
state.subjects = state.subjects.map((item) =>
|
|
588
|
+
item === found ? { ...item, credential } : item,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return { receipt: journal.prepare(undefined) };
|
|
593
|
+
}),
|
|
594
|
+
).pipe(
|
|
595
|
+
Effect.flatMap((receipt) => receipt.read),
|
|
596
|
+
Effect.mapError(() => PasswordUnavailable.make({})),
|
|
597
|
+
),
|
|
598
|
+
readForSubject: unsupported,
|
|
599
|
+
recoveryTarget: unsupported,
|
|
600
|
+
addIfAbsent: unsupported,
|
|
601
|
+
replaceIfCurrent: unsupported,
|
|
602
|
+
resetWithProof: unsupported,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
return Context.make(AuthenticationAuthority, authority).pipe(
|
|
606
|
+
Context.add(PasswordPersistence, passwords),
|
|
607
|
+
Context.add(auth.sessions.StatefulSessionPersistence, sessions),
|
|
608
|
+
Context.add(auth.sessions.SessionRepository, {
|
|
609
|
+
list: (input) =>
|
|
610
|
+
read((state, time) =>
|
|
611
|
+
Effect.gen(function* () {
|
|
612
|
+
const limit = yield* Schema.decodeEffect(
|
|
613
|
+
Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1000 })),
|
|
614
|
+
)(input.limit).pipe(Effect.mapError(unavailable));
|
|
615
|
+
|
|
616
|
+
const cursor =
|
|
617
|
+
input.cursor === undefined
|
|
618
|
+
? undefined
|
|
619
|
+
: yield* Schema.decodeEffect(SessionId)(input.cursor).pipe(
|
|
620
|
+
Effect.mapError(unavailable),
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const rows = state.sessions
|
|
624
|
+
.filter(
|
|
625
|
+
(row) =>
|
|
626
|
+
row.subjectId === input.subjectId &&
|
|
627
|
+
live(state, row, time) &&
|
|
628
|
+
(cursor === undefined || row.sessionId > cursor),
|
|
629
|
+
)
|
|
630
|
+
.sort((left, right) =>
|
|
631
|
+
left.sessionId < right.sessionId
|
|
632
|
+
? -1
|
|
633
|
+
: left.sessionId > right.sessionId
|
|
634
|
+
? 1
|
|
635
|
+
: 0,
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
const page = rows.slice(0, limit);
|
|
639
|
+
|
|
640
|
+
const metadata = yield* Effect.forEach(page, (row) =>
|
|
641
|
+
Schema.decodeEffect(Schema.toType(SessionMetadata))(row),
|
|
642
|
+
).pipe(Effect.mapError(unavailable));
|
|
643
|
+
|
|
644
|
+
const nextCursor = rows.length > limit ? page.at(-1)?.sessionId : undefined;
|
|
645
|
+
|
|
646
|
+
return { sessions: metadata, ...(nextCursor === undefined ? {} : { nextCursor }) };
|
|
647
|
+
}),
|
|
648
|
+
),
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
}),
|
|
652
|
+
).pipe(Layer.provide(hooksLayer));
|
|
@@ -2,6 +2,7 @@ import type { PreparedCommit } from "@yielded/auth/Hooks";
|
|
|
2
2
|
import type {
|
|
3
3
|
OAuthActionAuthorization,
|
|
4
4
|
OAuthAccountRevision,
|
|
5
|
+
OAuthLinkedAccountsRead,
|
|
5
6
|
OAuthRegistrationIntent,
|
|
6
7
|
OAuthExternalIdentity,
|
|
7
8
|
OAuthRegistrationAccess,
|
|
@@ -442,6 +443,15 @@ export interface OAuthAccountsMapping<
|
|
|
442
443
|
readonly subjectId: SubjectIdCodec<N>;
|
|
443
444
|
readonly clock: OAuthClock<Expression>;
|
|
444
445
|
readonly constraints: typeof requiredOAuthSignInConstraints;
|
|
446
|
+
/** Self-contained SQL predicate rechecking this invocation's current permission
|
|
447
|
+
* to view login metadata. Evaluated in every releasing read, alongside active
|
|
448
|
+
* subject/credential authority and ownership. Do not trust public assurance as
|
|
449
|
+
* credential provenance; bind session policy to application-owned rows as needed. */
|
|
450
|
+
readonly metadataAccess: (input: {
|
|
451
|
+
readonly invocation: OAuthLinkedAccountsRead["invocation"];
|
|
452
|
+
readonly moduleId: string;
|
|
453
|
+
readonly subjectId: N;
|
|
454
|
+
}) => Expression;
|
|
445
455
|
readonly eligibility: ReadonlyArray<OAuthEligibilityDescriptor<N, Expression, Descriptor>>;
|
|
446
456
|
readonly cleanup: ReadonlyArray<OAuthCleanupDescriptor<N, Expression, Descriptor>>;
|
|
447
457
|
readonly sessionInvalidation: "same-authority-immediate" | "original-absolute-expiry";
|