@yielded/auth-persistence 0.1.0-beta.31 → 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 (113) hide show
  1. package/README.md +9 -2
  2. package/dist/Adapter.mjs +8 -8
  3. package/dist/OAuthPersistence.mjs +10 -5
  4. package/dist/OAuthPersistence.mjs.map +1 -1
  5. package/dist/internal/configuration.d.mts +31 -5
  6. package/dist/internal/configuration.mjs.map +1 -1
  7. package/dist/internal/email-native.mjs +10 -2
  8. package/dist/internal/email-native.mjs.map +1 -1
  9. package/dist/internal/email-policy.mjs +3 -2
  10. package/dist/internal/email-policy.mjs.map +1 -1
  11. package/dist/internal/models/oauth-model.d.mts +4 -2
  12. package/dist/internal/models/oauth-model.mjs.map +1 -1
  13. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  14. package/dist/internal/oauth/native-accounts.mjs +6 -6
  15. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  16. package/dist/internal/oauth/native-connected.d.mts +1 -1
  17. package/dist/internal/oauth/native-connected.mjs +1 -1
  18. package/dist/internal/oauth/native-registration.mjs +19 -8
  19. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  20. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  21. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  22. package/dist/internal/oauth/native-state.mjs +21 -11
  23. package/dist/internal/oauth/native-state.mjs.map +1 -1
  24. package/dist/internal/oauth/state.d.mts +1 -1
  25. package/dist/internal/oauth/state.mjs +1 -1
  26. package/dist/internal/oauth-managed.mjs +182 -0
  27. package/dist/internal/oauth-managed.mjs.map +1 -0
  28. package/dist/internal/passkey-managed.mjs +1 -1
  29. package/dist/internal/passkey-native-registration.mjs +1 -1
  30. package/dist/internal/passkey-native-state.mjs +33 -23
  31. package/dist/internal/passkey-native-state.mjs.map +1 -1
  32. package/dist/internal/passkey-native-write.mjs +3 -3
  33. package/dist/internal/passkey-native-write.mjs.map +1 -1
  34. package/dist/internal/passkey-native.d.mts +15 -7
  35. package/dist/internal/passkey-native.mjs +2 -1
  36. package/dist/internal/passkey-native.mjs.map +1 -1
  37. package/dist/internal/passkey-policy.mjs +9 -7
  38. package/dist/internal/passkey-policy.mjs.map +1 -1
  39. package/dist/internal/password-native.mjs +9 -6
  40. package/dist/internal/password-native.mjs.map +1 -1
  41. package/dist/internal/password-policy.mjs +7 -6
  42. package/dist/internal/password-policy.mjs.map +1 -1
  43. package/dist/internal/persistence.mjs +96 -37
  44. package/dist/internal/persistence.mjs.map +1 -1
  45. package/dist/internal/phone-native.mjs +1 -1
  46. package/dist/internal/phone-native.mjs.map +1 -1
  47. package/dist/internal/session-native-authority.mjs +6 -5
  48. package/dist/internal/session-native-authority.mjs.map +1 -1
  49. package/dist/internal/session-native-login.d.mts +2 -2
  50. package/dist/internal/session-native-login.mjs +2 -1
  51. package/dist/internal/session-native-login.mjs.map +1 -1
  52. package/dist/internal/session-native-pending-read.mjs +26 -20
  53. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  54. package/dist/internal/session-native-record.mjs +6 -4
  55. package/dist/internal/session-native-record.mjs.map +1 -1
  56. package/dist/internal/session-native-state.mjs +3 -0
  57. package/dist/internal/session-native-state.mjs.map +1 -1
  58. package/dist/internal/session-native-stateful.d.mts +2 -2
  59. package/dist/internal/session-native-stateful.mjs +65 -37
  60. package/dist/internal/session-native-stateful.mjs.map +1 -1
  61. package/dist/internal/session-native-step-up.d.mts +2 -2
  62. package/dist/internal/session-native-step-up.mjs +14 -18
  63. package/dist/internal/session-native-step-up.mjs.map +1 -1
  64. package/dist/internal/session-native-validity.mjs +3 -1
  65. package/dist/internal/session-native-validity.mjs.map +1 -1
  66. package/dist/internal/sql-authority.mjs +5 -1
  67. package/dist/internal/sql-authority.mjs.map +1 -1
  68. package/dist/internal/sql-storage-validation.mjs +2 -1
  69. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  70. package/dist/internal/sql-table.d.mts +1 -0
  71. package/dist/internal/sql-table.mjs.map +1 -1
  72. package/dist/internal/step-up-state.mjs +19 -6
  73. package/dist/internal/step-up-state.mjs.map +1 -1
  74. package/dist/internal/storage-mapping.mjs +1 -1
  75. package/dist/internal/storage-tables.d.mts +237 -0
  76. package/dist/internal/storage-tables.mjs +68 -0
  77. package/dist/internal/storage-tables.mjs.map +1 -1
  78. package/dist/internal/storage-validation.d.mts +3 -1
  79. package/dist/internal/storage-validation.mjs +70 -17
  80. package/dist/internal/storage-validation.mjs.map +1 -1
  81. package/dist/internal/totp-native.mjs +1 -1
  82. package/package.json +1 -1
  83. package/src/OAuthPersistence.ts +7 -2
  84. package/src/internal/configuration.ts +46 -7
  85. package/src/internal/email-native.ts +16 -6
  86. package/src/internal/email-policy.ts +12 -3
  87. package/src/internal/models/oauth-model.ts +7 -1
  88. package/src/internal/oauth/native-accounts.ts +3 -3
  89. package/src/internal/oauth/native-registration.ts +17 -6
  90. package/src/internal/oauth/native-state.ts +38 -18
  91. package/src/internal/oauth-managed.ts +277 -0
  92. package/src/internal/passkey-native-state.ts +59 -52
  93. package/src/internal/passkey-native-write.ts +2 -2
  94. package/src/internal/passkey-native.ts +4 -2
  95. package/src/internal/passkey-policy.ts +13 -9
  96. package/src/internal/password-native.ts +17 -9
  97. package/src/internal/password-policy.ts +16 -8
  98. package/src/internal/persistence.ts +216 -70
  99. package/src/internal/phone-native.ts +1 -1
  100. package/src/internal/session-native-authority.ts +23 -7
  101. package/src/internal/session-native-login.ts +6 -3
  102. package/src/internal/session-native-pending-read.ts +46 -33
  103. package/src/internal/session-native-record.ts +6 -4
  104. package/src/internal/session-native-state.ts +3 -0
  105. package/src/internal/session-native-stateful.ts +144 -51
  106. package/src/internal/session-native-step-up.ts +27 -23
  107. package/src/internal/session-native-validity.ts +3 -1
  108. package/src/internal/sql-authority.ts +5 -1
  109. package/src/internal/sql-storage-validation.ts +53 -46
  110. package/src/internal/sql-table.ts +1 -0
  111. package/src/internal/step-up-state.ts +43 -6
  112. package/src/internal/storage-tables.ts +76 -0
  113. package/src/internal/storage-validation.ts +127 -27
@@ -1,3 +1,4 @@
1
+ import { AuthenticationClock } from "@yielded/auth/Operations";
1
2
  import { TokenDigest } from "@yielded/auth/Schema";
2
3
  import {
3
4
  SessionId,
@@ -50,14 +51,14 @@ export const makeConditionalSqlInsert = Effect.fnUntraced(function* () {
50
51
  };
51
52
  });
52
53
 
53
- /** Security columns identify the row and own its live time bounds. In particular
54
- * issuedAt may be assigned by UPDATE's database clock without rewriting a JSON
55
- * copy of prepared metadata. Claims/provenance decoding remains consumer-owned. */
54
+ /** Security columns own live time bounds and commit-time issuedAt adjustments,
55
+ * independently of prepared JSON metadata. Claims/provenance decoding remains consumer-owned. */
56
56
  export const makeNativeSessionRecords = Effect.fnUntraced(function* <Claims>(
57
57
  tables: NativeSqlTables,
58
58
  mapping: NativeSessionRecordMapping<Claims>,
59
59
  ) {
60
60
  const sql = (yield* SqlClient).withoutTransforms();
61
+ const { futureToleranceMillis } = yield* AuthenticationClock;
61
62
 
62
63
  const s = mapping.session,
63
64
  table = tables(s.table),
@@ -71,7 +72,8 @@ export const makeNativeSessionRecords = Effect.fnUntraced(function* <Claims>(
71
72
 
72
73
  const live = (t: SqlTable) =>
73
74
  sql.and([
74
- sql`${millis(t, s.issuedAt)} <= ${now}`,
75
+ sql`${millis(t, s.issuedAt)} <= ${now} + ${futureToleranceMillis}`,
76
+ sql`${millis(t, s.issuedAt)} < ${millis(t, s.expiresAt)}`,
75
77
  sql`${millis(t, s.expiresAt)} > ${now}`,
76
78
  sql`${millis(t, s.absoluteExpiresAt)} >= ${millis(t, s.expiresAt)}`,
77
79
  ]);
@@ -1,3 +1,4 @@
1
+ import { AuthenticationClock } from "@yielded/auth/Operations";
1
2
  import { reportAuthFailure } from "@yielded/auth/Persistence";
2
3
  import type { SubjectId } from "@yielded/auth/Schema";
3
4
  import {
@@ -98,6 +99,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
98
99
  batch = false,
99
100
  ) {
100
101
  const sql = (yield* SqlClient).withoutTransforms();
102
+ const clockPolicy = yield* AuthenticationClock;
101
103
 
102
104
  const s = mapping.subject,
103
105
  c = mapping.credential;
@@ -246,6 +248,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
246
248
 
247
249
  return {
248
250
  sql,
251
+ clockPolicy,
249
252
  subject,
250
253
  credential,
251
254
  joinedSubject,
@@ -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,14 +282,10 @@ 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);
@@ -303,6 +331,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
303
331
  authority.now,
304
332
  );
305
333
  }).pipe(
334
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
306
335
  Effect.mapError((cause) =>
307
336
  PersistenceMappingError.make({ operation: "decode", cause }),
308
337
  ),
@@ -326,10 +355,15 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
326
355
  classify(
327
356
  Effect.gen(function* () {
328
357
  const absolute = DateTime.toEpochMillis(input.record.absoluteExpiresAt),
329
- 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
+ );
330
363
 
331
364
  if (
332
365
  expires > absolute ||
366
+ preparedAt >= expires ||
333
367
  input.nextDigest === input.record.digest ||
334
368
  input.nextCredentialVersion === input.record.credentialVersion
335
369
  )
@@ -350,17 +384,21 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
350
384
  digest: input.nextDigest,
351
385
  credentialVersion: input.nextCredentialVersion,
352
386
  expiresAt: input.nextExpiresAt,
353
- issuedAt: DateTime.makeUnsafe(sampled),
387
+ issuedAt: DateTime.makeUnsafe(Math.max(sampled, preparedAt)),
354
388
  };
355
389
 
356
390
  const values = {
357
391
  ...s.encodeRotation(prepared),
358
392
  [s.digest]: prepared.digest,
359
- [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
+ ),
360
398
  [s.expiresAt]: s.encodeInstant(prepared.expiresAt),
361
399
  };
362
400
 
363
- 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}`;
364
402
  const update = sql`${table.update(values)} where ${condition}`;
365
403
  let record = prepared;
366
404
 
@@ -486,26 +524,81 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
486
524
  yield* Schema.decodeEffect(SessionId)(input.cursor),
487
525
  );
488
526
 
489
- const rows =
490
- 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`;
533
+
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];
491
561
 
492
- const decoded = yield* Effect.forEach(rows.slice(0, input.limit), (row) =>
493
- Effect.gen(function* () {
494
- const record = yield* records.decode(list.decode(row, "session_"));
562
+ if (first === undefined) return { sessions: [] };
563
+ if (!joinedRevisions) {
564
+ const subject = st.decode(first, "subject_");
495
565
 
496
566
  sessionInvariant(
497
- record.subjectId === input.subjectId &&
498
- (yield* mapping.subjectId.toSubject(st.decode(row, "subject_")[owner.id])) ===
499
- input.subjectId,
567
+ (yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId,
500
568
  );
501
569
 
502
- return yield* Schema.decodeEffect(Schema.toType(SessionMetadata))(record);
503
- }),
504
- );
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;
505
597
 
506
- const nextCursor = rows.length > input.limit ? decoded.at(-1)?.sessionId : undefined;
598
+ return { sessions: decoded, ...(nextCursor === undefined ? {} : { nextCursor }) };
599
+ }
507
600
 
508
- return { sessions: decoded, ...(nextCursor === undefined ? {} : { nextCursor }) };
601
+ return yield* sessionUnavailable();
509
602
  }),
510
603
  ),
511
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
  });
@@ -212,7 +212,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
212
212
  snapshot = yield* encodeStepUpIntent(intent);
213
213
 
214
214
  yield* state.guardPolicy(current);
215
- const condition = sql`${state.authorityCondition(intent.revision, current.native)} and ${original.condition} and ${now} >= ${DateTime.toEpochMillis(intent.sourceAuthenticatedAt)} and ${now} < ${DateTime.toEpochMillis(intent.expiresAt)}`;
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)}`;
216
216
 
217
217
  yield* pending
218
218
  .insert(
@@ -258,7 +258,11 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
258
258
 
259
259
  if (
260
260
  !sameSessionRevision(current.revision, intent.revision) ||
261
- !stepUpIntentLive(intent, mapping.source.kind, DateTime.makeUnsafe(current.now))
261
+ !(yield* stepUpIntentLive(
262
+ intent,
263
+ mapping.source.kind,
264
+ DateTime.makeUnsafe(current.now),
265
+ ))
262
266
  )
263
267
  return undefined;
264
268
 
@@ -268,6 +272,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
268
272
  }),
269
273
  )
270
274
  .pipe(
275
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
271
276
  Effect.flatMap((value) =>
272
277
  value === undefined
273
278
  ? Effect.fail(SessionStepUpInvalid.make({}))
@@ -313,20 +318,16 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
313
318
  return yield* SessionStepUpInvalid.make({});
314
319
  }
315
320
 
316
- 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),
317
327
  profile = yield* sessionEvidenceDeadline(evidence, intent.requirement, current.now);
318
328
 
319
- if (
320
- assuranceJson(
321
- AuthenticationAssurance.make({
322
- ...base.assessed.assurance,
323
- authenticatedAt: profile.assessed.assurance.authenticatedAt,
324
- }),
325
- ) !== assuranceJson(replacement.inspection.session.assurance)
326
- )
327
- return yield* SessionStepUpInvalid.make({});
328
-
329
329
  const deadline = Math.min(
330
+ prepared.validUntil,
330
331
  base.validUntil,
331
332
  profile.validUntil,
332
333
  DateTime.toEpochMillis(intent.expiresAt),
@@ -335,7 +336,8 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
335
336
 
336
337
  if (
337
338
  current.now >= deadline ||
338
- DateTime.toEpochMillis(replacement.inspection.session.issuedAt) > current.now ||
339
+ DateTime.toEpochMillis(replacement.inspection.session.issuedAt) - current.now >
340
+ state.clockPolicy.futureToleranceMillis ||
339
341
  DateTime.toEpochMillis(replacement.inspection.session.expiresAt) >
340
342
  DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt)
341
343
  )
@@ -430,6 +432,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
430
432
  sessionInvariant(
431
433
  actual !== undefined && sameSessionRevision(actual.revision, evidence.revision),
432
434
  );
435
+ yield* sessionEvidenceDeadline(evidence, plan.baseRequirement, actual.now);
433
436
  yield* sessionEvidenceDeadline(evidence, actual.requirement, actual.now);
434
437
  yield* sessionEvidenceDeadline(evidence, intent.requirement, actual.now);
435
438
  if (replacementRecord !== undefined && records !== undefined) {
@@ -438,6 +441,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
438
441
  sessionInvariant(row !== undefined && sameSessionRecord(row, replacementRecord));
439
442
  }
440
443
  }).pipe(
444
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
441
445
  Effect.mapError((cause) =>
442
446
  PersistenceMappingError.make({ operation: "decode", cause }),
443
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.