@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
@@ -0,0 +1,277 @@
1
+ import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import * as M from "@yielded/auth/OAuth";
3
+ import { AuthenticationFlowId } from "@yielded/auth/Sessions";
4
+ import { Context, type Crypto, DateTime, Effect, Schema } from "effect";
5
+ import { SqlClient } from "effect/sql";
6
+ import type { Fragment } from "effect/sql/Statement";
7
+
8
+ import {
9
+ PersistenceConfigurationError,
10
+ type MappingInput,
11
+ type OAuthFeature,
12
+ } from "./configuration";
13
+ import {
14
+ requiredOAuthConnectedConstraints,
15
+ requiredOAuthConnectedRevocationConstraints,
16
+ type OAuthConnectedPolicyInput,
17
+ } from "./models/oauth-connected-model";
18
+ import { requiredOAuthSignInConstraints } from "./models/oauth-model";
19
+ import type { NativeSqlTables } from "./native-sql-table";
20
+ import { makeNativeOAuthConnectedServices } from "./oauth/native-connected";
21
+ import type { OAuthNativeConnectedMapping } from "./oauth/native-connected-state";
22
+ import { makeNativeOAuthRevocationServices } from "./oauth/native-revocations";
23
+ import { makeNativeOAuthSignInServices } from "./oauth/native-sign-in";
24
+ import type { OAuthNativeReadMapping } from "./oauth/native-state";
25
+ import { sameRevision, satisfies } from "./oauth/state";
26
+ import { exactSqlText } from "./sql-change";
27
+ import { SqlBatchCommit } from "./sql-commit";
28
+ import { makeStorageClock } from "./storage-clock";
29
+ import { storageTables, type StorageRole } from "./storage-tables";
30
+
31
+ // Removed provider grants retain revocation custody for 30 days; claims may extend it.
32
+ const revocationRetentionMillis = 30 * 24 * 60 * 60 * 1000;
33
+ const encodeProfile = Schema.encodeSync(Schema.fromJsonString(M.OAuthConnectedProfile));
34
+ const encodePolicy = Schema.encodeSync(Schema.fromJsonString(M.OAuthConnectedPolicy));
35
+
36
+ const configurationError = (reason: string) => PersistenceConfigurationError.make({ reason });
37
+
38
+ /** Managed tables use the same native OAuth operations as explicit mappings. */
39
+ export const makeManagedOAuth = Effect.fnUntraced(function* (
40
+ storage: MappingInput,
41
+ tables: NativeSqlTables,
42
+ features: ReadonlyArray<OAuthFeature>,
43
+ ): Effect.fn.Return<
44
+ Context.Context<never>,
45
+ PersistenceConfigurationError,
46
+ Crypto.Crypto | LifecycleHooks | SqlClient.SqlClient | SqlBatchCommit
47
+ > {
48
+ const sql = (yield* SqlClient.SqlClient).withoutTransforms();
49
+ const batch = yield* SqlBatchCommit;
50
+
51
+ const clock = yield* makeStorageClock(storage).pipe(
52
+ Effect.mapError(() => configurationError("Cannot configure the OAuth storage clock")),
53
+ );
54
+
55
+ function mapped<Role extends StorageRole>(role: Role) {
56
+ const table = storage.tables[role];
57
+
58
+ if (table === undefined) throw configurationError(`Missing ${role} mapping`);
59
+
60
+ return {
61
+ table,
62
+ // Storage validation owns these logical column keys.
63
+ ...(Object.fromEntries(Object.keys(storageTables[role].columns).map((key) => [key, key])) as {
64
+ readonly [Key in keyof (typeof storageTables)[Role]["columns"]]: Key;
65
+ }),
66
+ };
67
+ }
68
+
69
+ const s = storage.subjects;
70
+ const subject = tables(s.table);
71
+ const credential = mapped("oauthCredentials");
72
+ const credentials = tables(credential.table);
73
+ const authority = mapped("credentials");
74
+ const factors = tables(authority.table);
75
+ const now = tables.expression(clock.engineNowMillis);
76
+ const policies = new Map<string, M.OAuthConnectedPolicy>();
77
+
78
+ for (const feature of features) {
79
+ if (feature.connected === undefined) continue;
80
+
81
+ const policy = yield* M.snapshotOAuth(M.OAuthConnectedPolicy, feature.connected).pipe(
82
+ Effect.mapError(() => configurationError(`Invalid OAuth policy for ${feature.moduleId}`)),
83
+ );
84
+
85
+ const previous = policies.get(feature.moduleId);
86
+
87
+ if (previous !== undefined && encodePolicy(previous) !== encodePolicy(policy))
88
+ return yield* configurationError(`Conflicting OAuth policies for ${feature.moduleId}`);
89
+ policies.set(feature.moduleId, policy);
90
+ }
91
+
92
+ const shared = {
93
+ subject: {
94
+ table: s.table,
95
+ id: s.id,
96
+ status: s.status,
97
+ securityRevision: s.securityRevision,
98
+ isActiveStatus: (value: unknown) => Object.is(value, s.activeValue),
99
+ activeCondition: sql`${subject.column(s.status)} = ${subject.value(s.status, s.activeValue)}`,
100
+ decodeAuthenticationRequirement: s.requirements,
101
+ },
102
+ subjectId: { toSubject: s.toSubject, toNative: s.toNative, equals: Object.is },
103
+ ownership: {
104
+ ...mapped("oauthIdentities"),
105
+ decodeSubjectId: (row: Record<string, unknown>) => row.subjectId,
106
+ encodeInsert: () => ({}),
107
+ },
108
+ credential: {
109
+ ...credential,
110
+ status: "active",
111
+ isActiveStatus: (value: unknown) => value === true,
112
+ activeCondition: sql`${credentials.column("active")} = ${credentials.value("active", true)}`,
113
+ },
114
+ authority: {
115
+ ...authority,
116
+ status: "active",
117
+ isActiveStatus: (value: unknown) => value === true,
118
+ activeCondition: sql`${factors.column("active")} = ${factors.value("active", true)}`,
119
+ },
120
+ clock,
121
+ };
122
+
123
+ function policyCondition(input: OAuthConnectedPolicyInput<unknown>): Fragment {
124
+ let moduleId: string;
125
+
126
+ switch (input.kind) {
127
+ case "sign-in":
128
+ moduleId = input.credential.moduleId;
129
+ break;
130
+ case "action":
131
+ moduleId = input.authorization.challenge.moduleId;
132
+ break;
133
+ case "metadata":
134
+ case "use":
135
+ moduleId = input.authorization.moduleId;
136
+ }
137
+ const policy = policies.get(moduleId);
138
+
139
+ if (policy === undefined) return sql`false`;
140
+ const grant = input.grant;
141
+
142
+ if (grant !== undefined) {
143
+ if (grant.moduleId !== moduleId || grant.subjectId !== input.revision.subjectId)
144
+ return sql`false`;
145
+ // Disconnect must remain possible after a profile is retired or removed.
146
+ if (input.kind !== "action" || input.operation !== "disconnect") {
147
+ const profile = policy.profiles.find(
148
+ (value) => value.key === grant.configuration.profile.key && value.issuance === "active",
149
+ );
150
+
151
+ if (
152
+ profile === undefined ||
153
+ encodeProfile(profile) !==
154
+ encodeProfile({
155
+ ...grant.configuration.profile,
156
+ issuance: "active",
157
+ })
158
+ )
159
+ return sql`false`;
160
+ }
161
+ }
162
+
163
+ const current = sql`exists(select 1 from ${subject.name} where ${subject.column(s.id)} = ${subject.value(s.id, input.subjectId)} and ${shared.subject.activeCondition} and ${exactSqlText(sql, subject.column(s.securityRevision), subject.value(s.securityRevision, input.revision.securityRevision))})`;
164
+
165
+ if (input.kind === "sign-in")
166
+ return features.some((feature) => feature.moduleId === moduleId && feature.signIn)
167
+ ? current
168
+ : sql`false`;
169
+ if (input.kind !== "action") {
170
+ // Mutable application policy shares the subject's guarded security revision.
171
+ return input.authorization.policyRevision === input.revision.securityRevision
172
+ ? current
173
+ : sql`false`;
174
+ }
175
+
176
+ const { authorization } = input;
177
+ const { challenge, evidence, requirement } = authorization;
178
+ const action = input.operation === "settle" ? "connected-complete" : "connected-disconnect";
179
+
180
+ if (
181
+ challenge.action !== action ||
182
+ !sameRevision(challenge.revision, input.revision) ||
183
+ !sameRevision(evidence.revision, input.revision) ||
184
+ evidence.flowId !== AuthenticationFlowId.make(challenge.flowId) ||
185
+ evidence.bindingDigest !== challenge.bindingDigest ||
186
+ !satisfies(evidence.proofs, requirement) ||
187
+ evidence.proofs.some(
188
+ (proof) =>
189
+ !input.revision.credentials.some((entry) => entry.credentialId === proof.credentialId),
190
+ )
191
+ )
192
+ return sql`false`;
193
+
194
+ const maximumAge = Math.min(requirement.maximumAgeMillis, policy.maximumEvidenceAgeMillis);
195
+ const verified = evidence.proofs.map((proof) => DateTime.toEpochMillis(proof.verifiedAt));
196
+
197
+ if (authorization.source._tag === "Session")
198
+ verified.push(DateTime.toEpochMillis(authorization.source.authenticatedAt));
199
+
200
+ return sql.and([
201
+ current,
202
+ sql`${now} < ${authorization.validUntilMillis}`,
203
+ ...verified.map((instant) => sql`${now} >= ${instant} and ${now} < ${instant + maximumAge}`),
204
+ ]);
205
+ }
206
+
207
+ let context: Context.Context<never> = Context.empty();
208
+
209
+ if (features.some((feature) => feature.signIn)) {
210
+ const mapping: OAuthNativeReadMapping = {
211
+ ...shared,
212
+ flow: { ...mapped("oauthSignInFlows"), encodeInsert: () => ({}) },
213
+ constraints: requiredOAuthSignInConstraints,
214
+ };
215
+
216
+ const services = yield* makeNativeOAuthSignInServices(tables, mapping, batch).pipe(
217
+ Effect.mapError(() => configurationError("Cannot configure OAuth sign-in persistence")),
218
+ );
219
+
220
+ context = Context.add(context, M.OAuthSignInPersistence, services.oauthSignInPersistence);
221
+ }
222
+ if (policies.size > 0) {
223
+ const job = { ...mapped("oauthConnectedRevocations"), encodeInsert: () => ({}) };
224
+ const grant = { ...mapped("oauthConnectedGrants"), encodeInsert: () => ({}) };
225
+
226
+ const mapping: OAuthNativeConnectedMapping = {
227
+ ...shared,
228
+ flow: { ...mapped("oauthConnectedFlows"), encodeInsert: () => ({}) },
229
+ grant,
230
+ policy: { condition: policyCondition },
231
+ constraints: requiredOAuthConnectedConstraints,
232
+ revocation: {
233
+ mode: "provider",
234
+ job,
235
+ retentionMillis: revocationRetentionMillis,
236
+ constraints: requiredOAuthConnectedRevocationConstraints,
237
+ },
238
+ otherReferences: ({ identityKey, subjectId }) =>
239
+ sql`exists(select 1 from ${credentials.name} where ${credentials.column("subjectId")} = ${credentials.value("subjectId", subjectId)} and ${exactSqlText(sql, credentials.column("identityKey"), credentials.value("identityKey", identityKey))})`,
240
+ };
241
+
242
+ const connected = yield* makeNativeOAuthConnectedServices(tables, mapping, batch).pipe(
243
+ Effect.mapError(() => configurationError("Cannot configure OAuth connected persistence")),
244
+ );
245
+
246
+ const revocations = yield* makeNativeOAuthRevocationServices(
247
+ tables,
248
+ {
249
+ ...mapping,
250
+ job,
251
+ constraints: requiredOAuthConnectedRevocationConstraints,
252
+ },
253
+ batch,
254
+ ).pipe(
255
+ Effect.mapError(() => configurationError("Cannot configure OAuth revocation persistence")),
256
+ );
257
+
258
+ context = context.pipe(
259
+ Context.add(M.OAuthConnectedPersistence, {
260
+ ...connected.oauthConnectedPersistence,
261
+ read: (input) =>
262
+ connected.oauthConnectedPersistence
263
+ .read(input)
264
+ .pipe(
265
+ Effect.map((snapshot) =>
266
+ snapshot === undefined
267
+ ? undefined
268
+ : { ...snapshot, policyRevision: snapshot.revision.securityRevision },
269
+ ),
270
+ ),
271
+ }),
272
+ Context.add(M.OAuthConnectedRevocations, revocations.oauthConnectedRevocations),
273
+ );
274
+ }
275
+
276
+ return context;
277
+ });
@@ -1,4 +1,5 @@
1
1
  /* oxlint-disable no-explicit-any -- physical mapping generics are erased only at this shared adapter boundary. */
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import * as M from "@yielded/auth/Passkey";
3
4
  import { Effect, Schema } from "effect";
4
5
  import { SqlClient } from "effect/sql";
@@ -9,6 +10,7 @@ import { sqlMapping, type NativeSqlTables } from "./native-sql-table";
9
10
  import { makePasskeyNativeFlow } from "./passkey-native-flow";
10
11
  import { passkeyCredentialKey } from "./passkey-policy";
11
12
  import { exactSqlText } from "./sql-change";
13
+ import { canJoinTextColumns } from "./storage-validation";
12
14
  import type { TableModel } from "./table-model";
13
15
 
14
16
  // Physical expression types are validated by NativeSqlTables at this boundary.
@@ -226,75 +228,79 @@ export const makePasskeyNativeReadState = Effect.fnUntraced(function* (
226
228
  return sql`(select * from ${table.name} where ${active(kind)}) as ${sql(alias)}`;
227
229
  };
228
230
 
231
+ const joinedIds = yield* canJoinTextColumns([
232
+ subject.unencodedTextColumn?.(read.subject.id),
233
+ credential.unencodedTextColumn?.(read.credential.subjectId),
234
+ factor.unencodedTextColumn?.(read.authority.subjectId),
235
+ ]).pipe(Effect.mapError(() => M.PasskeyUnavailable.make({})));
236
+
229
237
  const lookup = Effect.fnUntraced(function* (rpId: string, protocolCredentialId: string) {
230
238
  const key = yield* passkeyCredentialKey(rpId, protocolCredentialId);
239
+ let nativeId: unknown;
231
240
 
232
- if (
233
- subject.unencodedTextColumn?.(read.subject.id) !== undefined &&
234
- credential.unencodedTextColumn?.(read.credential.subjectId) !== undefined &&
235
- factor.unencodedTextColumn?.(read.authority.subjectId) !== undefined
236
- ) {
237
- const s = joinedSubject,
238
- c = joinedCredential,
239
- f = joinedFactor;
240
-
241
- const rows =
242
- yield* sql<PasskeyNativeRow>`select ${s.fields("s_")}, ${c.fields("c_")}, ${f.fields("f_")}
243
- from ${selected("credential", "passkey_credential")}
244
- join ${selected("subject", "passkey_subject")} on ${exactSqlText(sql, s.column(read.subject.id), c.column(read.credential.subjectId))}
245
- left join ${selected("authority", "passkey_factor")} on ${exactSqlText(sql, f.column(read.authority.subjectId), c.column(read.credential.subjectId))}
246
- where ${exactSqlText(sql, c.column(read.credential.credentialKey), c.value(read.credential.credentialKey, key))} limit 65`;
241
+ if (!joinedIds) {
242
+ const found =
243
+ yield* sql<PasskeyNativeRow>`select ${credential.fields("c_")} from ${credential.name} where ${exactSqlText(sql, credential.column(read.credential.credentialKey), credential.value(read.credential.credentialKey, key))} and ${active("credential")}`;
247
244
 
248
- if (rows.length === 0 || rows.length > 64) return undefined;
249
- const row = c.decode(rows[0]!, "c_");
250
- const subjectRow = s.decode(rows[0]!, "s_");
251
- const owner = read.credential.decodeSubjectId(row);
245
+ if (found.length !== 1) return undefined;
246
+ const row = credential.decode(found[0]!, "c_");
252
247
 
253
248
  invariant(
254
249
  row[read.credential.rpId] === rpId &&
255
250
  row[read.credential.protocolCredentialId] === protocolCredentialId,
256
251
  );
257
- invariant(
258
- rows.every((selected) => {
259
- const other = c.decode(selected, "c_");
260
- const subjectValue = s.decode(selected, "s_");
261
-
262
- return (
263
- sameId(owner, read.credential.decodeSubjectId(other)) &&
264
- sameId(owner, read.subject.decodeId(subjectValue)) &&
265
- other[read.credential.credentialId] === row[read.credential.credentialId] &&
266
- other[read.credential.credentialRevision] === row[read.credential.credentialRevision] &&
267
- subjectValue[read.subject.securityRevision] ===
268
- subjectRow[read.subject.securityRevision]
269
- );
270
- }),
271
- );
272
-
273
- return yield* decodeCredential(
274
- row,
275
- revision(
276
- subjectRow,
277
- rows.map((row) => f.decode(row, "f_")),
278
- ),
279
- subjectRow,
280
- );
252
+ nativeId = read.credential.decodeSubjectId(row);
281
253
  }
282
254
 
255
+ const s = joinedSubject,
256
+ c = joinedCredential,
257
+ f = joinedFactor;
258
+
259
+ const bindOwner = (table: typeof subject, key: string) =>
260
+ joinedIds
261
+ ? exactSqlText(sql, table.column(key), c.column(read.credential.subjectId))
262
+ : sql`${table.column(key)} = ${table.value(key, nativeId)}`;
263
+
283
264
  const rows =
284
- yield* sql<PasskeyNativeRow>`select ${credential.fields("c_")} from ${credential.name} where ${credential.column(read.credential.credentialKey)} = ${credential.value(read.credential.credentialKey, key)} and ${active("credential")}`;
265
+ yield* sql<PasskeyNativeRow>`select ${s.fields("s_")}, ${c.fields("c_")}, ${f.fields("f_")}
266
+ from ${selected("credential", "passkey_credential")}
267
+ join ${selected("subject", "passkey_subject")} on ${bindOwner(s, read.subject.id)}
268
+ left join ${selected("authority", "passkey_factor")} on ${bindOwner(f, read.authority.subjectId)}
269
+ where ${exactSqlText(sql, c.column(read.credential.credentialKey), c.value(read.credential.credentialKey, key))}${joinedIds ? sql`` : sql` and ${bindOwner(c, read.credential.subjectId)}`} limit 65`;
285
270
 
286
- if (rows.length !== 1) return undefined;
287
- const row = credential.decode(rows[0]!, "c_");
271
+ if (rows.length === 0 || rows.length > 64) return undefined;
272
+ const row = c.decode(rows[0]!, "c_");
273
+ const subjectRow = s.decode(rows[0]!, "s_");
274
+ const owner = read.credential.decodeSubjectId(row);
288
275
 
289
276
  invariant(
290
- row[read.credential.rpId] === rpId &&
277
+ (joinedIds || sameId(owner, nativeId)) &&
278
+ row[read.credential.rpId] === rpId &&
291
279
  row[read.credential.protocolCredentialId] === protocolCredentialId,
292
280
  );
293
- const current = yield* readAuthority(read.credential.decodeSubjectId(row), false);
281
+ invariant(
282
+ rows.every((selected) => {
283
+ const other = c.decode(selected, "c_");
284
+ const subjectValue = s.decode(selected, "s_");
294
285
 
295
- return current === undefined
296
- ? undefined
297
- : yield* decodeCredential(row, current.revision, current.row);
286
+ return (
287
+ sameId(owner, read.credential.decodeSubjectId(other)) &&
288
+ sameId(owner, read.subject.decodeId(subjectValue)) &&
289
+ other[read.credential.credentialId] === row[read.credential.credentialId] &&
290
+ other[read.credential.credentialRevision] === row[read.credential.credentialRevision] &&
291
+ subjectValue[read.subject.securityRevision] === subjectRow[read.subject.securityRevision]
292
+ );
293
+ }),
294
+ );
295
+
296
+ return yield* decodeCredential(
297
+ row,
298
+ revision(
299
+ subjectRow,
300
+ rows.map((row) => f.decode(row, "f_")),
301
+ ),
302
+ subjectRow,
303
+ );
298
304
  });
299
305
 
300
306
  return {
@@ -326,9 +332,10 @@ export const makePasskeyNativeState = Effect.fnUntraced(function* (
326
332
  mapping: PasskeyNativeMapping,
327
333
  ) {
328
334
  const state = yield* makePasskeyNativeReadState(tables, mapping.read, mapping.clock);
335
+ const clockPolicy = yield* AuthenticationClock;
329
336
  const ceremony = yield* makePasskeyNativeFlow(tables, mapping);
330
337
 
331
- return { ...state, mapping, ceremony, flow: ceremony.flow, access: ceremony.access };
338
+ return { ...state, clockPolicy, mapping, ceremony, flow: ceremony.flow, access: ceremony.access };
332
339
  });
333
340
 
334
341
  export type PasskeyNativeReadState = Effect.Success<ReturnType<typeof makePasskeyNativeReadState>>;
@@ -334,7 +334,7 @@ export const makePasskeyNativeManagement = (
334
334
 
335
335
  const requirement = yield* mapping.write.policy.requirement(current.row, "enroll-begin");
336
336
 
337
- const authority = assessPasskeyAction(
337
+ const authority = yield* assessPasskeyAction(
338
338
  {
339
339
  moduleId: mapping.moduleId,
340
340
  revision: current.revision,
@@ -487,7 +487,7 @@ export const makePasskeyNativeManagement = (
487
487
  return yield* preparePasskeyNative({ _tag: "Rejected" }, prepare);
488
488
  const requirement = yield* mapping.write.policy.requirement(current.row, "remove");
489
489
 
490
- const authority = assessPasskeyAction(
490
+ const authority = yield* assessPasskeyAction(
491
491
  {
492
492
  moduleId: mapping.moduleId,
493
493
  revision: current.revision,
@@ -1,4 +1,5 @@
1
1
  import { CurrentCommitJournal } from "@yielded/auth/Hooks";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import * as M from "@yielded/auth/Passkey";
3
4
  import { Crypto, Effect, Option, Schema } from "effect";
4
5
  import { SqlError } from "effect/sql/SqlError";
@@ -66,9 +67,10 @@ const services = Effect.fnUntraced(function* (
66
67
  effect: Effect.Effect<A, E, R>,
67
68
  mode: "transaction" | "statement" = "transaction",
68
69
  ) =>
69
- batch === undefined
70
+ (batch === undefined
70
71
  ? executor.run(provide(effect), mode)
71
- : executor.batch(provide(effect)).pipe(Effect.provideService(SqlBatchCommit, batch));
72
+ : executor.batch(provide(effect)).pipe(Effect.provideService(SqlBatchCommit, batch))
73
+ ).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
72
74
 
73
75
  const advisory = <A, E, R>(effect: Effect.Effect<A, E, R>) => executor.read(provide(effect));
74
76
 
@@ -1,3 +1,4 @@
1
+ import { AuthenticationClock } from "@yielded/auth/Operations";
1
2
  import {
2
3
  PasskeyUnavailable,
3
4
  type PasskeyCeremony,
@@ -154,10 +155,11 @@ export interface PasskeyActionFacts {
154
155
  }
155
156
 
156
157
  /** Reject stale bindings before evaluating application requirements. */
157
- export const validPasskeyActionEvidence = (
158
+ export const validPasskeyActionEvidence = Effect.fnUntraced(function* (
158
159
  facts: Pick<PasskeyActionFacts, "moduleId" | "revision" | "expected" | "nowMillis">,
159
160
  authorization: PasskeyActionAuthorization,
160
- ) => {
161
+ ): Effect.fn.Return<boolean> {
162
+ const { futureToleranceMillis } = yield* AuthenticationClock;
161
163
  const { challenge, evidence } = authorization;
162
164
  const { expected, revision, nowMillis: now } = facts;
163
165
 
@@ -190,23 +192,25 @@ export const validPasskeyActionEvidence = (
190
192
  if (
191
193
  evidence.proofs.some(
192
194
  (proof) =>
193
- DateTime.toEpochMillis(proof.verifiedAt) > now ||
195
+ DateTime.toEpochMillis(proof.verifiedAt) - now > futureToleranceMillis ||
194
196
  !evidence.revision.credentials.some((item) => item.credentialId === proof.credentialId),
195
197
  )
196
198
  )
197
199
  return false;
198
200
 
199
201
  return true;
200
- };
202
+ });
201
203
 
202
204
  /** SQL policy predicates remain native. This authorizes the exact evidence and
203
205
  * returns the deadline every backend must retain through its final commit. */
204
- export const assessPasskeyAction = (
206
+ export const assessPasskeyAction = Effect.fnUntraced(function* (
205
207
  facts: PasskeyActionFacts,
206
208
  authorization: PasskeyActionAuthorization,
207
209
  policy: PasskeyManagementPolicy,
208
- ) => {
209
- if (!validPasskeyActionEvidence(facts, authorization)) return undefined;
210
+ ): Effect.fn.Return<
211
+ { readonly notBeforeMillis: number; readonly expiresBeforeMillis: number } | undefined
212
+ > {
213
+ if (!(yield* validPasskeyActionEvidence(facts, authorization))) return undefined;
210
214
  const { evidence } = authorization;
211
215
  const now = facts.nowMillis;
212
216
  let expiresBefore = Infinity;
@@ -219,7 +223,7 @@ export const assessPasskeyAction = (
219
223
  const age = Math.min(policy.maximumEvidenceAgeMillis, requirement.maximumAgeMillis);
220
224
 
221
225
  const fresh = evidence.proofs.filter(
222
- (proof) => now - DateTime.toEpochMillis(proof.verifiedAt) < age,
226
+ (proof) => Math.max(0, now - DateTime.toEpochMillis(proof.verifiedAt)) < age,
223
227
  );
224
228
 
225
229
  const factors = new Set(fresh.flatMap((proof) => proof.factors));
@@ -243,4 +247,4 @@ export const assessPasskeyAction = (
243
247
  }
244
248
 
245
249
  return { notBeforeMillis: now, expiresBeforeMillis: expiresBefore };
246
- };
250
+ });
@@ -1,5 +1,6 @@
1
1
  import { CurrentCommitJournal, type LifecycleHooks } from "@yielded/auth/Hooks";
2
2
  import type { LoginIdentifier } from "@yielded/auth/Identity";
3
+ import { AuthenticationClock } from "@yielded/auth/Operations";
3
4
  import * as P from "@yielded/auth/Password";
4
5
  import { ProofRedemptionInput } from "@yielded/auth/Proofs";
5
6
  import type { SubjectId } from "@yielded/auth/Schema";
@@ -52,6 +53,7 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
52
53
  SqlClient | LifecycleHooks | Crypto.Crypto | SqlBatchCommit
53
54
  > {
54
55
  const batch = yield* SqlBatchCommit;
56
+ const clockPolicy = yield* AuthenticationClock;
55
57
  const sql = (yield* SqlClient).withoutTransforms();
56
58
  const executor = yield* makeSqlCommitExecutor(unavailable);
57
59
  const crypto = yield* Crypto.Crypto;
@@ -87,11 +89,12 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
87
89
  sql`${table.column(key)} = ${table.value(key, nativeId)}`;
88
90
 
89
91
  const run = <A, E, R>(work: Effect.Effect<A, E, R>) =>
90
- batch === undefined
92
+ (batch === undefined
91
93
  ? executor.run(work.pipe(Effect.provideService(Crypto.Crypto, crypto)))
92
94
  : executor
93
95
  .batch(work.pipe(Effect.provideService(Crypto.Crypto, crypto)))
94
- .pipe(Effect.provideService(SqlBatchCommit, batch));
96
+ .pipe(Effect.provideService(SqlBatchCommit, batch))
97
+ ).pipe(Effect.provideService(AuthenticationClock, clockPolicy));
95
98
 
96
99
  const read = Effect.fnUntraced(function* (
97
100
  moduleId: string,
@@ -346,7 +349,9 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
346
349
  verifierVersion !== input.credential?.verifierVersion,
347
350
  );
348
351
 
349
- const freshUntil = (requirement: AuthenticationRequirement) => {
352
+ const freshUntil = Effect.fnUntraced(function* (
353
+ requirement: AuthenticationRequirement,
354
+ ): Effect.fn.Return<number | undefined> {
350
355
  const deadlines = [
351
356
  ...new Set(
352
357
  input.authorization.evidence.proofs.map(
@@ -357,10 +362,13 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
357
362
  .filter((time) => time > state.now)
358
363
  .sort((a, b) => a - b);
359
364
 
360
- return deadlines.find(
361
- (time) => !passwordEvidenceSatisfiedAt(input.authorization.evidence, requirement, time),
362
- );
363
- };
365
+ for (const time of deadlines) {
366
+ if (
367
+ !(yield* passwordEvidenceSatisfiedAt(input.authorization.evidence, requirement, time))
368
+ )
369
+ return time;
370
+ }
371
+ });
364
372
 
365
373
  if (batch !== undefined)
366
374
  yield* guardSqlitePolicy({
@@ -370,8 +378,8 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
370
378
  values: state.policyValues,
371
379
  });
372
380
 
373
- const requestedDeadline = freshUntil(input.authorization.requirement),
374
- currentDeadline = freshUntil(requirement);
381
+ const requestedDeadline = yield* freshUntil(input.authorization.requirement),
382
+ currentDeadline = yield* freshUntil(requirement);
375
383
 
376
384
  ensure(requestedDeadline !== undefined && currentDeadline !== undefined);
377
385
  const deadline = Math.min(requestedDeadline, currentDeadline);
@@ -1,4 +1,5 @@
1
1
  import type { LoginIdentifier } from "@yielded/auth/Identity";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import {
3
4
  PasswordUnavailable,
4
5
  snapshotPasswordCredential,
@@ -47,11 +48,13 @@ export const samePasswordCredential = (
47
48
  samePasswordIdentifier(left.identifier, right.identifier) &&
48
49
  Redacted.value(left.verifier) === Redacted.value(right.verifier);
49
50
 
50
- export const passwordEvidenceSatisfiedAt = (
51
+ export const passwordEvidenceSatisfiedAt = Effect.fnUntraced(function* (
51
52
  evidence: AuthenticationEvidence,
52
53
  requirement: AuthenticationRequirement,
53
54
  now: number,
54
- ): boolean => {
55
+ ): Effect.fn.Return<boolean> {
56
+ const { futureToleranceMillis } = yield* AuthenticationClock;
57
+
55
58
  if (evidence.proofs.length > 64 || evidence.revision.credentials.length > 64) return false;
56
59
  const revisions = new Map<string, string>();
57
60
 
@@ -60,12 +63,17 @@ export const passwordEvidenceSatisfiedAt = (
60
63
  revisions.set(item.credentialId, item.revision);
61
64
  }
62
65
  if (evidence.proofs.some((proof) => !revisions.has(proof.credentialId))) return false;
63
- if (evidence.proofs.some((proof) => now < DateTime.toEpochMillis(proof.verifiedAt))) return false;
66
+ if (
67
+ evidence.proofs.some(
68
+ (proof) => DateTime.toEpochMillis(proof.verifiedAt) - now > futureToleranceMillis,
69
+ )
70
+ )
71
+ return false;
64
72
 
65
73
  const eligible = evidence.proofs.filter((proof) => {
66
- const age = now - DateTime.toEpochMillis(proof.verifiedAt);
74
+ const age = Math.max(0, now - DateTime.toEpochMillis(proof.verifiedAt));
67
75
 
68
- return age >= 0 && age < requirement.maximumAgeMillis;
76
+ return age < requirement.maximumAgeMillis;
69
77
  });
70
78
 
71
79
  const factors = new Set(eligible.flatMap((proof) => proof.factors));
@@ -81,7 +89,7 @@ export const passwordEvidenceSatisfiedAt = (
81
89
  (!alternative.phishingResistant || proof.phishingResistant),
82
90
  ),
83
91
  );
84
- };
92
+ });
85
93
 
86
94
  export const snapshotPasswordMutation = Effect.fn("PasswordPersistence.snapshotMutation")(
87
95
  function* (input: PasswordMutationInput) {
@@ -272,8 +280,8 @@ export const validatePasswordMutation = Effect.fnUntraced(function* (
272
280
  const requirement = yield* current.requirement;
273
281
 
274
282
  if (
275
- !passwordEvidenceSatisfiedAt(evidence, input.authorization.requirement, now) ||
276
- !passwordEvidenceSatisfiedAt(evidence, requirement, now)
283
+ !(yield* passwordEvidenceSatisfiedAt(evidence, input.authorization.requirement, now)) ||
284
+ !(yield* passwordEvidenceSatisfiedAt(evidence, requirement, now))
277
285
  )
278
286
  return undefined;
279
287
  if (