@yielded/auth-persistence 0.1.0-beta.30 → 0.1.0-beta.32

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 (130) hide show
  1. package/README.md +11 -7
  2. package/dist/Adapter.d.mts +1 -1
  3. package/dist/Adapter.mjs +8 -8
  4. package/dist/OAuthPersistence.mjs +10 -5
  5. package/dist/OAuthPersistence.mjs.map +1 -1
  6. package/dist/internal/configuration.d.mts +49 -13
  7. package/dist/internal/configuration.mjs.map +1 -1
  8. package/dist/internal/email-native.mjs +10 -2
  9. package/dist/internal/email-native.mjs.map +1 -1
  10. package/dist/internal/email-policy.mjs +3 -2
  11. package/dist/internal/email-policy.mjs.map +1 -1
  12. package/dist/internal/models/oauth-model.d.mts +4 -2
  13. package/dist/internal/models/oauth-model.mjs.map +1 -1
  14. package/dist/internal/models/password-model.d.mts +2 -1
  15. package/dist/internal/models/password-model.mjs.map +1 -1
  16. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  17. package/dist/internal/oauth/native-accounts.mjs +6 -6
  18. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  19. package/dist/internal/oauth/native-connected.d.mts +1 -1
  20. package/dist/internal/oauth/native-connected.mjs +1 -1
  21. package/dist/internal/oauth/native-registration.mjs +19 -8
  22. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  23. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  24. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  25. package/dist/internal/oauth/native-state.mjs +21 -11
  26. package/dist/internal/oauth/native-state.mjs.map +1 -1
  27. package/dist/internal/oauth/state.d.mts +1 -1
  28. package/dist/internal/oauth/state.mjs +1 -1
  29. package/dist/internal/oauth-managed.mjs +182 -0
  30. package/dist/internal/oauth-managed.mjs.map +1 -0
  31. package/dist/internal/passkey-managed.mjs +5 -3
  32. package/dist/internal/passkey-managed.mjs.map +1 -1
  33. package/dist/internal/passkey-native-registration.mjs +1 -1
  34. package/dist/internal/passkey-native-state.mjs +33 -23
  35. package/dist/internal/passkey-native-state.mjs.map +1 -1
  36. package/dist/internal/passkey-native-write.mjs +3 -3
  37. package/dist/internal/passkey-native-write.mjs.map +1 -1
  38. package/dist/internal/passkey-native.d.mts +15 -7
  39. package/dist/internal/passkey-native.mjs +2 -1
  40. package/dist/internal/passkey-native.mjs.map +1 -1
  41. package/dist/internal/passkey-policy.mjs +9 -7
  42. package/dist/internal/passkey-policy.mjs.map +1 -1
  43. package/dist/internal/password-native.mjs +22 -5
  44. package/dist/internal/password-native.mjs.map +1 -1
  45. package/dist/internal/password-policy.mjs +7 -6
  46. package/dist/internal/password-policy.mjs.map +1 -1
  47. package/dist/internal/password-registration-native.d.mts +1 -1
  48. package/dist/internal/persistence.d.mts +3 -0
  49. package/dist/internal/persistence.mjs +99 -42
  50. package/dist/internal/persistence.mjs.map +1 -1
  51. package/dist/internal/phone-native.mjs +1 -1
  52. package/dist/internal/phone-native.mjs.map +1 -1
  53. package/dist/internal/registration.mjs +17 -8
  54. package/dist/internal/registration.mjs.map +1 -1
  55. package/dist/internal/session-native-authority.mjs +8 -6
  56. package/dist/internal/session-native-authority.mjs.map +1 -1
  57. package/dist/internal/session-native-login.d.mts +2 -2
  58. package/dist/internal/session-native-login.mjs +4 -2
  59. package/dist/internal/session-native-login.mjs.map +1 -1
  60. package/dist/internal/session-native-pending-read.mjs +26 -20
  61. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  62. package/dist/internal/session-native-record.mjs +6 -4
  63. package/dist/internal/session-native-record.mjs.map +1 -1
  64. package/dist/internal/session-native-state.mjs +24 -8
  65. package/dist/internal/session-native-state.mjs.map +1 -1
  66. package/dist/internal/session-native-stateful.d.mts +2 -2
  67. package/dist/internal/session-native-stateful.mjs +67 -38
  68. package/dist/internal/session-native-stateful.mjs.map +1 -1
  69. package/dist/internal/session-native-step-up.d.mts +2 -2
  70. package/dist/internal/session-native-step-up.mjs +17 -19
  71. package/dist/internal/session-native-step-up.mjs.map +1 -1
  72. package/dist/internal/session-native-validity.mjs +3 -1
  73. package/dist/internal/session-native-validity.mjs.map +1 -1
  74. package/dist/internal/sql-authority.mjs +5 -1
  75. package/dist/internal/sql-authority.mjs.map +1 -1
  76. package/dist/internal/sql-storage-validation.mjs +2 -1
  77. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  78. package/dist/internal/sql-table.d.mts +1 -0
  79. package/dist/internal/sql-table.mjs.map +1 -1
  80. package/dist/internal/sqlite-policy-guard.mjs +23 -0
  81. package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
  82. package/dist/internal/step-up-state.mjs +19 -6
  83. package/dist/internal/step-up-state.mjs.map +1 -1
  84. package/dist/internal/storage-mapping.d.mts +9 -5
  85. package/dist/internal/storage-mapping.mjs +17 -5
  86. package/dist/internal/storage-mapping.mjs.map +1 -1
  87. package/dist/internal/storage-tables.d.mts +237 -0
  88. package/dist/internal/storage-tables.mjs +68 -0
  89. package/dist/internal/storage-tables.mjs.map +1 -1
  90. package/dist/internal/storage-validation.d.mts +3 -1
  91. package/dist/internal/storage-validation.mjs +70 -17
  92. package/dist/internal/storage-validation.mjs.map +1 -1
  93. package/dist/internal/totp-native.mjs +1 -1
  94. package/package.json +1 -1
  95. package/src/OAuthPersistence.ts +7 -2
  96. package/src/internal/configuration.ts +72 -15
  97. package/src/internal/email-native.ts +16 -6
  98. package/src/internal/email-policy.ts +12 -3
  99. package/src/internal/models/oauth-model.ts +7 -1
  100. package/src/internal/models/password-model.ts +2 -1
  101. package/src/internal/oauth/native-accounts.ts +3 -3
  102. package/src/internal/oauth/native-registration.ts +17 -6
  103. package/src/internal/oauth/native-state.ts +38 -18
  104. package/src/internal/oauth-managed.ts +277 -0
  105. package/src/internal/passkey-managed.ts +8 -2
  106. package/src/internal/passkey-native-state.ts +59 -52
  107. package/src/internal/passkey-native-write.ts +2 -2
  108. package/src/internal/passkey-native.ts +4 -2
  109. package/src/internal/passkey-policy.ts +13 -9
  110. package/src/internal/password-native.ts +45 -11
  111. package/src/internal/password-policy.ts +16 -8
  112. package/src/internal/persistence.ts +360 -204
  113. package/src/internal/phone-native.ts +1 -1
  114. package/src/internal/registration.ts +53 -40
  115. package/src/internal/session-native-authority.ts +26 -8
  116. package/src/internal/session-native-login.ts +9 -4
  117. package/src/internal/session-native-pending-read.ts +46 -33
  118. package/src/internal/session-native-record.ts +6 -4
  119. package/src/internal/session-native-state.ts +32 -10
  120. package/src/internal/session-native-stateful.ts +147 -52
  121. package/src/internal/session-native-step-up.ts +31 -24
  122. package/src/internal/session-native-validity.ts +3 -1
  123. package/src/internal/sql-authority.ts +5 -1
  124. package/src/internal/sql-storage-validation.ts +53 -46
  125. package/src/internal/sql-table.ts +1 -0
  126. package/src/internal/sqlite-policy-guard.ts +34 -0
  127. package/src/internal/step-up-state.ts +43 -6
  128. package/src/internal/storage-mapping.ts +40 -4
  129. package/src/internal/storage-tables.ts +76 -0
  130. package/src/internal/storage-validation.ts +127 -27
@@ -1,4 +1,5 @@
1
1
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import {
3
4
  SessionConflict,
4
5
  SessionInvalid,
@@ -10,6 +11,7 @@ import {
10
11
  type StatefulSessionPersistence,
11
12
  type StatefulSessionRecord,
12
13
  type SessionRepository,
14
+ type SessionUnavailable,
13
15
  } from "@yielded/auth/Sessions";
14
16
  import { DateTime, Effect, Option, Schema } from "effect";
15
17
  import type { SqlClient } from "effect/sql/SqlClient";
@@ -43,6 +45,7 @@ import {
43
45
  registerSqlBatchPostcondition,
44
46
  registerSqlPostcondition,
45
47
  } from "./sql-commit";
48
+ import { canJoinTextColumns, withStorageValidation } from "./storage-validation";
46
49
  import type { AnyTableModel } from "./table-model";
47
50
 
48
51
  export type NativeStatefulSessionMapping<Claims> = StatefulSessionMapping<
@@ -63,7 +66,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
63
66
  readonly statefulSessionPersistence: StatefulSessionPersistence<Claims>;
64
67
  readonly sessionRepository: SessionRepository;
65
68
  },
66
- never,
69
+ SessionUnavailable,
67
70
  SqlClient | LifecycleHooks | SqlBatchCommit
68
71
  > {
69
72
  const batch = yield* SqlBatchCommit;
@@ -89,11 +92,12 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
89
92
  work: Effect.Effect<A, E, R>,
90
93
  mode: "transaction" | "statement" = "transaction",
91
94
  ) =>
92
- batch === undefined
95
+ (batch === undefined
93
96
  ? executor.operation(normalizeSessionOperation(work), mode)
94
97
  : executor
95
98
  .operationBatch(normalizeSessionOperation(work))
96
- .pipe(Effect.provideService(SqlBatchCommit, batch));
99
+ .pipe(Effect.provideService(SqlBatchCommit, batch))
100
+ ).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
97
101
 
98
102
  const stage = Effect.fnUntraced(function* (statement: Fragment, expected?: number) {
99
103
  if (batch !== undefined) {
@@ -118,6 +122,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
118
122
 
119
123
  sessionInvariant(rows.length === 1);
120
124
  }).pipe(
125
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
121
126
  Effect.mapError((cause) => PersistenceMappingError.make({ operation: "decode", cause })),
122
127
  ),
123
128
  });
@@ -139,47 +144,63 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
139
144
  ),
140
145
  );
141
146
 
147
+ const [joinedIds, joinedRevisions] = yield* Effect.forEach(
148
+ [
149
+ [
150
+ table.unencodedTextColumn?.(s.subjectId),
151
+ state.subject.unencodedTextColumn?.(mapping.subject.id),
152
+ ],
153
+ [
154
+ table.unencodedTextColumn?.(s.securityRevision),
155
+ state.subject.unencodedTextColumn?.(mapping.subject.securityRevision),
156
+ ],
157
+ ],
158
+ canJoinTextColumns,
159
+ ).pipe(withStorageValidation, Effect.mapError(sessionUnavailable));
160
+
142
161
  const verify = Effect.fnUntraced(function* (digest: StatefulSessionRecord<Claims>["digest"]) {
143
162
  const subject = state.subject.as("verified_subject"),
144
163
  session = table.as("verified_session"),
145
164
  owner = mapping.subject;
146
165
 
147
- if (
148
- table.unencodedTextColumn?.(s.subjectId) !== undefined &&
149
- state.subject.unencodedTextColumn?.(owner.id) !== undefined
150
- ) {
151
- const rows =
152
- yield* sql`select ${session.fields("session_")}, ${subject.fields("subject_")} from ${session.name} join ${subject.name} on ${exactSqlText(sql, subject.column(owner.id), session.column(s.subjectId))} and ${state.activeSubject(subject)} and ${exactSqlText(sql, subject.column(owner.securityRevision), session.column(s.securityRevision))} where ${exact(session, s.digest, digest)} and ${live(session)} limit 2`;
153
-
154
- sessionInvariant(rows.length <= 1);
155
- if (rows[0] === undefined) return undefined;
156
- const record = yield* records.decode(session.decode(rows[0], "session_"));
157
- const decoded = subject.decode(rows[0], "subject_");
166
+ let discoveredSubjectId: StatefulSessionRecord<Claims>["subjectId"] | undefined;
167
+ let nativeSubject: unknown;
158
168
 
159
- sessionInvariant(
160
- (yield* mapping.subjectId.toSubject(decoded[owner.id])) === record.subjectId &&
161
- decoded[owner.securityRevision] === record.securityRevision &&
162
- owner.isActiveStatus(decoded[owner.status]),
163
- );
169
+ if (!joinedIds) {
170
+ const discovered = yield* records.read(digest);
164
171
 
165
- return record;
172
+ if (discovered === undefined) return undefined;
173
+ discoveredSubjectId = discovered.subjectId;
174
+ nativeSubject = yield* mapping.subjectId.toNative(discoveredSubjectId);
166
175
  }
167
- const record = yield* records.read(digest);
168
176
 
169
- if (record === undefined) return undefined;
170
- const nativeSubject = yield* mapping.subjectId.toNative(record.subjectId);
177
+ const owns = joinedIds
178
+ ? exactSqlText(sql, subject.column(owner.id), session.column(s.subjectId))
179
+ : sql`${state.id(subject, owner.id, nativeSubject)} and ${state.id(session, s.subjectId, nativeSubject)}`;
180
+
181
+ const revision = joinedRevisions
182
+ ? exactSqlText(
183
+ sql,
184
+ subject.column(owner.securityRevision),
185
+ session.column(s.securityRevision),
186
+ )
187
+ : sql`true`;
171
188
 
172
189
  const rows =
173
- yield* sql`select ${subject.fields("subject_")} from ${subject.name} where ${state.id(subject, owner.id, nativeSubject)} and ${state.activeSubject(subject)} and ${state.exact(subject, owner.securityRevision, record.securityRevision)} and ${now} < ${DateTime.toEpochMillis(record.expiresAt)} limit 2`;
190
+ yield* sql`select ${session.fields("session_")}, ${subject.fields("subject_")} from ${session.name} join ${subject.name} on ${owns} and ${state.activeSubject(subject)} and ${revision} where ${exact(session, s.digest, digest)} and ${live(session)} limit 2`;
174
191
 
175
192
  sessionInvariant(rows.length <= 1);
176
193
  if (rows[0] === undefined) return undefined;
194
+ const record = yield* records.decode(session.decode(rows[0], "session_"));
195
+ const decoded = subject.decode(rows[0], "subject_");
196
+
177
197
  sessionInvariant(
178
- (yield* mapping.subjectId.toSubject(subject.decode(rows[0], "subject_")[owner.id])) ===
179
- record.subjectId,
198
+ (joinedIds || record.subjectId === discoveredSubjectId) &&
199
+ (yield* mapping.subjectId.toSubject(decoded[owner.id])) === record.subjectId &&
200
+ owner.isActiveStatus(decoded[owner.status]),
180
201
  );
181
202
 
182
- return record;
203
+ return decoded[owner.securityRevision] === record.securityRevision ? record : undefined;
183
204
  });
184
205
 
185
206
  const persistence: StatefulSessionPersistence<Claims> = {
@@ -201,10 +222,21 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
201
222
  current.now,
202
223
  );
203
224
 
204
- const expires = DateTime.toEpochMillis(input.session.expiresAt),
225
+ const issued = Math.max(
226
+ current.now,
227
+ DateTime.toEpochMillis(input.now),
228
+ DateTime.toEpochMillis(input.session.issuedAt),
229
+ ...input.evidence.proofs.map((proof) => DateTime.toEpochMillis(proof.verifiedAt)),
230
+ ),
231
+ expires = DateTime.toEpochMillis(input.session.expiresAt),
205
232
  absolute = DateTime.toEpochMillis(input.session.absoluteExpiresAt);
206
233
 
207
- if (current.now >= expires || expires > absolute)
234
+ if (
235
+ issued - current.now > state.clockPolicy.futureToleranceMillis ||
236
+ issued >= expires ||
237
+ current.now >= expires ||
238
+ expires > absolute
239
+ )
208
240
  return yield* StaleAuthentication.make({});
209
241
  let pendingExpiry = Infinity;
210
242
 
@@ -250,18 +282,16 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
250
282
  sessionId,
251
283
  subjectId: current.revision.subjectId,
252
284
  securityRevision: current.revision.securityRevision,
253
- assurance:
254
- input.handoffSourceSessionId === undefined
255
- ? assessment.assessed.assurance
256
- : input.session.assurance,
257
285
  provenance: yield* snapshotSessionAuthenticationProvenance({
258
286
  evidence: input.evidence,
259
287
  }),
260
- issuedAt: DateTime.makeUnsafe(current.now),
288
+ issuedAt: DateTime.makeUnsafe(issued),
261
289
  };
262
290
 
263
291
  const deadline = Math.min(expires, assessment.validUntil, pendingExpiry);
264
- const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
292
+
293
+ yield* state.guardPolicy(current);
294
+ const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
265
295
 
266
296
  const values = {
267
297
  ...s.encodeInsert(record, { subjectId: current.native, sessionId: nativeSession }),
@@ -301,6 +331,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
301
331
  authority.now,
302
332
  );
303
333
  }).pipe(
334
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
304
335
  Effect.mapError((cause) =>
305
336
  PersistenceMappingError.make({ operation: "decode", cause }),
306
337
  ),
@@ -324,10 +355,15 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
324
355
  classify(
325
356
  Effect.gen(function* () {
326
357
  const absolute = DateTime.toEpochMillis(input.record.absoluteExpiresAt),
327
- expires = DateTime.toEpochMillis(input.nextExpiresAt);
358
+ expires = DateTime.toEpochMillis(input.nextExpiresAt),
359
+ preparedAt = Math.max(
360
+ DateTime.toEpochMillis(input.now),
361
+ DateTime.toEpochMillis(input.record.issuedAt),
362
+ );
328
363
 
329
364
  if (
330
365
  expires > absolute ||
366
+ preparedAt >= expires ||
331
367
  input.nextDigest === input.record.digest ||
332
368
  input.nextCredentialVersion === input.record.credentialVersion
333
369
  )
@@ -348,17 +384,21 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
348
384
  digest: input.nextDigest,
349
385
  credentialVersion: input.nextCredentialVersion,
350
386
  expiresAt: input.nextExpiresAt,
351
- issuedAt: DateTime.makeUnsafe(sampled),
387
+ issuedAt: DateTime.makeUnsafe(Math.max(sampled, preparedAt)),
352
388
  };
353
389
 
354
390
  const values = {
355
391
  ...s.encodeRotation(prepared),
356
392
  [s.digest]: prepared.digest,
357
- [s.issuedAt]: tables.expression(mapping.clock.fromMillis(now)),
393
+ [s.issuedAt]: tables.expression(
394
+ mapping.clock.fromMillis(
395
+ sql`case when ${now} >= ${preparedAt} then ${now} else ${preparedAt} end`,
396
+ ),
397
+ ),
358
398
  [s.expiresAt]: s.encodeInstant(prepared.expiresAt),
359
399
  };
360
400
 
361
- const condition = sql`${yield* records.owner(input.record)} and ${live(table)} and ${now} < ${expires} and ${records.millis(table, s.absoluteExpiresAt)} = ${absolute}`;
401
+ const condition = sql`${yield* records.owner(input.record)} and ${live(table)} and ${preparedAt} <= ${now} + ${state.clockPolicy.futureToleranceMillis} and ${now} < ${expires} and ${records.millis(table, s.absoluteExpiresAt)} = ${absolute}`;
362
402
  const update = sql`${table.update(values)} where ${condition}`;
363
403
  let record = prepared;
364
404
 
@@ -484,26 +524,81 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
484
524
  yield* Schema.decodeEffect(SessionId)(input.cursor),
485
525
  );
486
526
 
487
- const rows =
488
- yield* sql`select ${list.fields("session_")}, ${st.fields("subject_")} from ${list.name} join ${st.name} on ${state.id(st, owner.id, nativeSubject)} and ${state.activeSubject(st)} and ${exactSqlText(sql, st.column(owner.securityRevision), list.column(s.securityRevision))} where ${state.id(list, s.subjectId, nativeSubject)} and ${live(list)}${cursor === undefined ? sql`` : sql` and ${list.column(s.sessionId)} > ${list.value(s.sessionId, cursor)}`} order by ${list.column(s.sessionId)} limit ${input.limit + 1}`;
527
+ let revision: StatefulSessionRecord<Claims>["securityRevision"] | undefined;
528
+
529
+ // Bind both revisions in SQL so stale rows cannot consume a page.
530
+ if (!joinedRevisions) {
531
+ const subjects =
532
+ yield* sql`select ${st.fields("subject_")} from ${st.name} where ${state.id(st, owner.id, nativeSubject)} and ${state.activeSubject(st)} limit 2`;
489
533
 
490
- const decoded = yield* Effect.forEach(rows.slice(0, input.limit), (row) =>
491
- Effect.gen(function* () {
492
- const record = yield* records.decode(list.decode(row, "session_"));
534
+ sessionInvariant(subjects.length <= 1);
535
+ if (subjects[0] === undefined) return { sessions: [] };
536
+ const subject = st.decode(subjects[0], "subject_");
537
+
538
+ sessionInvariant(
539
+ (yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId,
540
+ );
541
+ revision = yield* Schema.decodeUnknownEffect(SessionMetadata.fields.securityRevision)(
542
+ subject[owner.securityRevision],
543
+ );
544
+ }
545
+
546
+ // Keep the owner in the page snapshot even when its discovered revision changed.
547
+ // Retry that read once; continued authority changes are not pagination exhaustion.
548
+ for (let attempt = 0; attempt < 2; attempt++) {
549
+ const currentRevision = joinedRevisions
550
+ ? exactSqlText(
551
+ sql,
552
+ st.column(owner.securityRevision),
553
+ list.column(s.securityRevision),
554
+ )
555
+ : sql`${state.exact(st, owner.securityRevision, revision)} and ${exact(list, s.securityRevision, revision)}`;
556
+
557
+ const rows =
558
+ yield* sql`select ${list.fields("session_")}, ${st.fields("subject_")}, ${list.column(s.subjectId)} as session_owner from ${st.name} left join ${list.name} on ${state.id(list, s.subjectId, nativeSubject)} and ${currentRevision} and ${live(list)}${cursor === undefined ? sql`` : sql` and ${list.column(s.sessionId)} > ${list.value(s.sessionId, cursor)}`} where ${state.id(st, owner.id, nativeSubject)} and ${state.activeSubject(st)} order by ${list.column(s.sessionId)} limit ${input.limit + 1}`;
559
+
560
+ const first = rows[0];
561
+
562
+ if (first === undefined) return { sessions: [] };
563
+ if (!joinedRevisions) {
564
+ const subject = st.decode(first, "subject_");
493
565
 
494
566
  sessionInvariant(
495
- record.subjectId === input.subjectId &&
496
- (yield* mapping.subjectId.toSubject(st.decode(row, "subject_")[owner.id])) ===
497
- input.subjectId,
567
+ (yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId,
498
568
  );
499
569
 
500
- return yield* Schema.decodeEffect(Schema.toType(SessionMetadata))(record);
501
- }),
502
- );
570
+ const current = yield* Schema.decodeUnknownEffect(
571
+ SessionMetadata.fields.securityRevision,
572
+ )(subject[owner.securityRevision]);
573
+
574
+ if (current !== revision) {
575
+ revision = current;
576
+ continue;
577
+ }
578
+ }
579
+ if (first.session_owner === null) return { sessions: [] };
580
+
581
+ const decoded = yield* Effect.forEach(rows.slice(0, input.limit), (row) =>
582
+ Effect.gen(function* () {
583
+ const record = yield* records.decode(list.decode(row, "session_"));
584
+ const subject = st.decode(row, "subject_");
585
+
586
+ sessionInvariant(
587
+ record.subjectId === input.subjectId &&
588
+ (yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId &&
589
+ subject[owner.securityRevision] === record.securityRevision,
590
+ );
591
+
592
+ return yield* Schema.decodeEffect(Schema.toType(SessionMetadata))(record);
593
+ }),
594
+ );
595
+
596
+ const nextCursor = rows.length > input.limit ? decoded.at(-1)?.sessionId : undefined;
503
597
 
504
- const nextCursor = rows.length > input.limit ? decoded.at(-1)?.sessionId : undefined;
598
+ return { sessions: decoded, ...(nextCursor === undefined ? {} : { nextCursor }) };
599
+ }
505
600
 
506
- return { sessions: decoded, ...(nextCursor === undefined ? {} : { nextCursor }) };
601
+ return yield* sessionUnavailable();
507
602
  }),
508
603
  ),
509
604
  };
@@ -1,15 +1,15 @@
1
1
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
- import { AuthenticationAssurance } from "@yielded/auth/Operations";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
3
3
  import {
4
4
  SessionConflict,
5
5
  SessionStepUpInvalid,
6
6
  StaleAuthentication,
7
- SessionMetadata,
8
7
  type SessionStepUpPersistence,
9
8
  type SessionStepUpIntent,
10
9
  type StatefulSessionRecord,
10
+ type SessionUnavailable,
11
11
  } from "@yielded/auth/Sessions";
12
- import { DateTime, Effect, Option, Schema } from "effect";
12
+ import { DateTime, Effect, Option } from "effect";
13
13
  import type { SqlClient } from "effect/sql/SqlClient";
14
14
  import type { Fragment } from "effect/sql/Statement";
15
15
 
@@ -58,14 +58,12 @@ export type NativeSessionStepUpMapping<Claims> = SessionStepUpMapping<
58
58
  unknown
59
59
  >;
60
60
 
61
- const assuranceJson = Schema.encodeSync(Schema.fromJsonString(SessionMetadata.fields.assurance));
62
-
63
61
  export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Claims>(
64
62
  tables: NativeSqlTables,
65
63
  mapping: NativeSessionStepUpMapping<Claims>,
66
64
  ): Effect.fn.Return<
67
65
  { readonly sessionStepUpPersistence: SessionStepUpPersistence<Claims> },
68
- never,
66
+ SessionUnavailable,
69
67
  SqlClient | LifecycleHooks | SqlBatchCommit
70
68
  > {
71
69
  const batch = yield* SqlBatchCommit;
@@ -92,11 +90,12 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
92
90
  work: Effect.Effect<A, E, R>,
93
91
  mode: "transaction" | "statement" = "transaction",
94
92
  ) =>
95
- batch === undefined
93
+ (batch === undefined
96
94
  ? executor.operation(normalizeSessionOperation(work), mode)
97
95
  : executor
98
96
  .operationBatch(normalizeSessionOperation(work))
99
- .pipe(Effect.provideService(SqlBatchCommit, batch));
97
+ .pipe(Effect.provideService(SqlBatchCommit, batch))
98
+ ).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
100
99
 
101
100
  const decode = Effect.fnUntraced(function* (stored: StoredSessionPending) {
102
101
  const intent = yield* decodeStepUpIntent(stored.snapshot);
@@ -118,7 +117,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
118
117
  intent: Omit<SessionStepUpIntent, "version">,
119
118
  instant: number,
120
119
  ) {
121
- if (!stepUpIntentLive(intent, mapping.source.kind, DateTime.makeUnsafe(instant)))
120
+ if (!(yield* stepUpIntentLive(intent, mapping.source.kind, DateTime.makeUnsafe(instant))))
122
121
  return undefined;
123
122
  const subject = yield* mapping.subjectId.toNative(intent.revision.subjectId);
124
123
 
@@ -178,6 +177,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
178
177
  check: Effect.gen(function* () {
179
178
  sessionInvariant((yield* sql`select 1 where ${condition}`).length === 1);
180
179
  }).pipe(
180
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
181
181
  Effect.mapError((cause) => PersistenceMappingError.make({ operation: "decode", cause })),
182
182
  ),
183
183
  });
@@ -211,7 +211,8 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
211
211
  const intent = { ...input, version },
212
212
  snapshot = yield* encodeStepUpIntent(intent);
213
213
 
214
- const condition = sql`${state.authorityCondition(intent.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${original.condition} and ${now} >= ${DateTime.toEpochMillis(intent.sourceAuthenticatedAt)} and ${now} < ${DateTime.toEpochMillis(intent.expiresAt)}`;
214
+ yield* state.guardPolicy(current);
215
+ const condition = sql`${state.authorityCondition(intent.revision, current.native)} and ${original.condition} and ${now} >= ${current.now} and ${DateTime.toEpochMillis(intent.sourceAuthenticatedAt)} <= ${now} + ${state.clockPolicy.futureToleranceMillis} and ${now} < ${DateTime.toEpochMillis(intent.expiresAt)}`;
215
216
 
216
217
  yield* pending
217
218
  .insert(
@@ -257,7 +258,11 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
257
258
 
258
259
  if (
259
260
  !sameSessionRevision(current.revision, intent.revision) ||
260
- !stepUpIntentLive(intent, mapping.source.kind, DateTime.makeUnsafe(current.now))
261
+ !(yield* stepUpIntentLive(
262
+ intent,
263
+ mapping.source.kind,
264
+ DateTime.makeUnsafe(current.now),
265
+ ))
261
266
  )
262
267
  return undefined;
263
268
 
@@ -267,6 +272,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
267
272
  }),
268
273
  )
269
274
  .pipe(
275
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
270
276
  Effect.flatMap((value) =>
271
277
  value === undefined
272
278
  ? Effect.fail(SessionStepUpInvalid.make({}))
@@ -312,20 +318,16 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
312
318
  return yield* SessionStepUpInvalid.make({});
313
319
  }
314
320
 
315
- const base = yield* sessionEvidenceDeadline(evidence, current.requirement, current.now),
321
+ const prepared = yield* sessionEvidenceDeadline(
322
+ evidence,
323
+ plan.baseRequirement,
324
+ current.now,
325
+ ),
326
+ base = yield* sessionEvidenceDeadline(evidence, current.requirement, current.now),
316
327
  profile = yield* sessionEvidenceDeadline(evidence, intent.requirement, current.now);
317
328
 
318
- if (
319
- assuranceJson(
320
- AuthenticationAssurance.make({
321
- ...base.assessed.assurance,
322
- authenticatedAt: profile.assessed.assurance.authenticatedAt,
323
- }),
324
- ) !== assuranceJson(replacement.inspection.session.assurance)
325
- )
326
- return yield* SessionStepUpInvalid.make({});
327
-
328
329
  const deadline = Math.min(
330
+ prepared.validUntil,
329
331
  base.validUntil,
330
332
  profile.validUntil,
331
333
  DateTime.toEpochMillis(intent.expiresAt),
@@ -334,13 +336,16 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
334
336
 
335
337
  if (
336
338
  current.now >= deadline ||
337
- DateTime.toEpochMillis(replacement.inspection.session.issuedAt) > current.now ||
339
+ DateTime.toEpochMillis(replacement.inspection.session.issuedAt) - current.now >
340
+ state.clockPolicy.futureToleranceMillis ||
338
341
  DateTime.toEpochMillis(replacement.inspection.session.expiresAt) >
339
342
  DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt)
340
343
  )
341
344
  return yield* SessionStepUpInvalid.make({});
342
345
  const snapshot = yield* encodeStepUpIntent(intent);
343
- const authority = sql`${state.authorityCondition(evidence.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
346
+
347
+ yield* state.guardPolicy(current);
348
+ const authority = sql`${state.authorityCondition(evidence.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
344
349
 
345
350
  yield* pending.consume(
346
351
  "StepUp",
@@ -427,6 +432,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
427
432
  sessionInvariant(
428
433
  actual !== undefined && sameSessionRevision(actual.revision, evidence.revision),
429
434
  );
435
+ yield* sessionEvidenceDeadline(evidence, plan.baseRequirement, actual.now);
430
436
  yield* sessionEvidenceDeadline(evidence, actual.requirement, actual.now);
431
437
  yield* sessionEvidenceDeadline(evidence, intent.requirement, actual.now);
432
438
  if (replacementRecord !== undefined && records !== undefined) {
@@ -435,6 +441,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
435
441
  sessionInvariant(row !== undefined && sameSessionRecord(row, replacementRecord));
436
442
  }
437
443
  }).pipe(
444
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
438
445
  Effect.mapError((cause) =>
439
446
  PersistenceMappingError.make({ operation: "decode", cause }),
440
447
  ),
@@ -1,4 +1,5 @@
1
1
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import {
3
4
  SessionInvalid,
4
5
  StaleAuthentication,
@@ -46,6 +47,7 @@ export const makeNativeSignedSessionValidityServices = Effect.fnUntraced(functio
46
47
  SqlClient | LifecycleHooks | SqlBatchCommit
47
48
  > {
48
49
  const batch = yield* SqlBatchCommit;
50
+ const { futureToleranceMillis } = yield* AuthenticationClock;
49
51
 
50
52
  const sql = (yield* SqlClient).withoutTransforms(),
51
53
  executor = yield* makeSqlCommitExecutor(sessionUnavailable);
@@ -113,7 +115,7 @@ export const makeNativeSignedSessionValidityServices = Effect.fnUntraced(functio
113
115
  nativeSession = yield* mapping.sessionId.toNative(record.sessionId);
114
116
 
115
117
  const rows =
116
- yield* sql`select ${subject.fields("subject_")} from ${subject.name} where ${id(subject, s.id, nativeSubject)} and ${id(subject, s.status, s.activeStatusValue)} and ${exact(subject, s.securityRevision, record.securityRevision)} and ${now} >= ${DateTime.toEpochMillis(record.issuedAt)} and ${now} < ${DateTime.toEpochMillis(record.expiresAt)} and ${DateTime.toEpochMillis(record.expiresAt)} <= ${DateTime.toEpochMillis(record.absoluteExpiresAt)} and not exists(select 1 from ${tombstone.name} where ${key(nativeSubject, nativeSession)}) limit 2`;
118
+ yield* sql`select ${subject.fields("subject_")} from ${subject.name} where ${id(subject, s.id, nativeSubject)} and ${id(subject, s.status, s.activeStatusValue)} and ${exact(subject, s.securityRevision, record.securityRevision)} and ${DateTime.toEpochMillis(record.issuedAt)} <= ${now} + ${futureToleranceMillis} and ${DateTime.toEpochMillis(record.issuedAt)} < ${DateTime.toEpochMillis(record.expiresAt)} and ${now} < ${DateTime.toEpochMillis(record.expiresAt)} and ${DateTime.toEpochMillis(record.expiresAt)} <= ${DateTime.toEpochMillis(record.absoluteExpiresAt)} and not exists(select 1 from ${tombstone.name} where ${key(nativeSubject, nativeSession)}) limit 2`;
117
119
 
118
120
  sessionInvariant(rows.length <= 1);
119
121
  if (rows[0] === undefined) return false;
@@ -1,4 +1,5 @@
1
1
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import { reportAuthDiagnostic } from "@yielded/auth/Persistence";
2
3
  import { SessionUnavailable, type AuthenticationAuthority } from "@yielded/auth/Sessions";
3
4
  import { Effect } from "effect";
4
5
  import { SqlClient } from "effect/sql";
@@ -32,10 +33,13 @@ export const makeAuthenticationAuthorityServices = Effect.fnUntraced(function* <
32
33
  > {
33
34
  const sql = yield* SqlClient.SqlClient;
34
35
 
35
- if (!sql.onDialectOrElse({ pg: () => true, sqlite: () => true, orElse: () => false }))
36
+ if (!sql.onDialectOrElse({ pg: () => true, sqlite: () => true, orElse: () => false })) {
37
+ yield* reportAuthDiagnostic("persistence-validation", "configuration");
38
+
36
39
  return yield* PersistenceConfigurationError.make({
37
40
  reason: "Authentication authority requires PostgreSQL or SQLite",
38
41
  });
42
+ }
39
43
  yield* validateSqlStorage(mapping).pipe(Effect.mapError(() => SessionUnavailable.make({})));
40
44
 
41
45
  // Erase mapped native ID callback variance, never decoded authentication data.
@@ -1,3 +1,4 @@
1
+ import { reportAuthDiagnostic } from "@yielded/auth/Persistence";
1
2
  import { Effect } from "effect";
2
3
  import { SqlClient } from "effect/sql";
3
4
 
@@ -7,57 +8,63 @@ import { storageKeyPlans } from "./storage-plans";
7
8
  import { validateStorageBatch, type StorageValidation } from "./storage-validation";
8
9
 
9
10
  /** Check declared mappings against physical keys before acquiring OAuth services. */
10
- export const validateSqlStorage = Effect.fnUntraced(function* (mapping: unknown) {
11
- const plans = yield* Effect.try({
12
- try: () =>
13
- storageKeyPlans(mapping, (table) => {
14
- if (!(table instanceof Table)) throw new Error("Expected a SQL table");
11
+ export const validateSqlStorage = Effect.fnUntraced(
12
+ function* (mapping: unknown) {
13
+ const plans = yield* Effect.try({
14
+ try: () =>
15
+ storageKeyPlans(mapping, (table) => {
16
+ if (!(table instanceof Table)) throw new Error("Expected a SQL table");
15
17
 
16
- return table.columns;
17
- }),
18
- catch: () =>
19
- PersistenceMappingError.make({ operation: "mapping", cause: "Invalid storage key mapping" }),
20
- });
21
-
22
- const client = yield* SqlClient.SqlClient;
18
+ return table.columns;
19
+ }),
20
+ catch: () =>
21
+ PersistenceMappingError.make({
22
+ operation: "mapping",
23
+ cause: "Invalid storage key mapping",
24
+ }),
25
+ });
23
26
 
24
- const dialect = client.onDialectOrElse({
25
- pg: () => "pg" as const,
26
- sqlite: () => "sqlite" as const,
27
- orElse: () => undefined,
28
- });
27
+ const client = yield* SqlClient.SqlClient;
29
28
 
30
- if (dialect === undefined)
31
- return yield* PersistenceMappingError.make({
32
- operation: "mapping",
33
- cause: "OAuth persistence requires PostgreSQL or SQLite",
29
+ const dialect = client.onDialectOrElse({
30
+ pg: () => "pg" as const,
31
+ sqlite: () => "sqlite" as const,
32
+ orElse: () => undefined,
34
33
  });
35
- const validations: StorageValidation[] = [];
36
34
 
37
- for (const plan of plans) {
38
- if (!(plan.table instanceof Table))
35
+ if (dialect === undefined)
39
36
  return yield* PersistenceMappingError.make({
40
37
  operation: "mapping",
41
- cause: "Expected a SQL table",
38
+ cause: "OAuth persistence requires PostgreSQL or SQLite",
42
39
  });
43
- validations.push({
44
- table: {
45
- name: plan.table.name,
46
- ...(plan.table.schema === undefined ? {} : { schema: plan.table.schema }),
47
- columns: Object.fromEntries(
48
- Object.entries(plan.table.columns).map(([key, column]) => [key, column.options]),
49
- ),
50
- },
51
- required: [plan.keys],
52
- });
53
- }
54
- yield* validateStorageBatch(dialect, validations).pipe(
55
- Effect.provideService(SqlClient.SqlClient, client),
56
- Effect.mapError(() =>
57
- PersistenceMappingError.make({
58
- operation: "mapping",
59
- cause: "Required SQL storage key is unavailable",
60
- }),
61
- ),
62
- );
63
- });
40
+ const validations: StorageValidation[] = [];
41
+
42
+ for (const plan of plans) {
43
+ if (!(plan.table instanceof Table))
44
+ return yield* PersistenceMappingError.make({
45
+ operation: "mapping",
46
+ cause: "Expected a SQL table",
47
+ });
48
+ validations.push({
49
+ table: {
50
+ name: plan.table.name,
51
+ ...(plan.table.schema === undefined ? {} : { schema: plan.table.schema }),
52
+ columns: Object.fromEntries(
53
+ Object.entries(plan.table.columns).map(([key, column]) => [key, column.options]),
54
+ ),
55
+ },
56
+ required: [plan.keys],
57
+ });
58
+ }
59
+ yield* validateStorageBatch(dialect, validations).pipe(
60
+ Effect.provideService(SqlClient.SqlClient, client),
61
+ Effect.mapError(() =>
62
+ PersistenceMappingError.make({
63
+ operation: "mapping",
64
+ cause: "Required SQL storage key is unavailable",
65
+ }),
66
+ ),
67
+ );
68
+ },
69
+ Effect.tapError(() => reportAuthDiagnostic("persistence-validation", "mapping")),
70
+ );
@@ -22,6 +22,7 @@ export const identifier = (name: string) => '"' + name.replaceAll('"', '""') + '
22
22
 
23
23
  export interface ColumnOptions {
24
24
  readonly name: string;
25
+ /** Driver value representation, not the database column's physical SQL type. */
25
26
  readonly type: "text" | "integer" | "boolean";
26
27
  readonly nullable?: boolean;
27
28
  }