@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
@@ -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
+ });
@@ -14,13 +14,14 @@ import {
14
14
  requiredPasskeyPersistenceConstraints,
15
15
  } from "./models/passkey-model";
16
16
  import type { NativeSqlTables } from "./native-sql-table";
17
- import { makeNativePasskeyServices } from "./passkey-native";
17
+ import { makeBatchPasskeyServices, makeNativePasskeyServices } from "./passkey-native";
18
18
  import type { PasskeyNativeMapping, PasskeyNativeRead } from "./passkey-native-state";
19
19
  import {
20
20
  makePasskeyNativeManagement,
21
21
  type NativePasskeyManagementMapping,
22
22
  } from "./passkey-native-write";
23
23
  import { anySqlCondition } from "./sql-change";
24
+ import { SqlBatchCommit } from "./sql-commit";
24
25
  import { storageTables, type StorageRole } from "./storage-tables";
25
26
 
26
27
  export interface ComposedPasskeyInput {
@@ -255,6 +256,8 @@ const mappings = Effect.fnUntraced(function* (
255
256
  /** Managed and mapped tables feed the same native passkey statements. */
256
257
  export const makeManagedPasskeys = Effect.fnUntraced(
257
258
  function* (input: ComposedPasskeyInput, tables: NativeSqlTables) {
259
+ const batch = yield* SqlBatchCommit;
260
+
258
261
  const policies = yield* Effect.forEach(input.features, (feature) =>
259
262
  Effect.map(feature.policy, (policy) => ({ feature, policy })),
260
263
  );
@@ -275,7 +278,10 @@ export const makeManagedPasskeys = Effect.fnUntraced(
275
278
 
276
279
  for (const feature of input.features) {
277
280
  const selected = feature.management ? mapping.management(feature) : mapping.base(feature);
278
- const base = yield* makeNativePasskeyServices(tables, selected);
281
+
282
+ const base = yield* batch === undefined
283
+ ? makeNativePasskeyServices(tables, selected)
284
+ : makeBatchPasskeyServices(tables, selected);
279
285
 
280
286
  services.set(feature.moduleId, base);
281
287
  if (feature.management)
@@ -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
+ });