@yielded/auth-persistence 0.1.0-beta.29 → 0.1.0-beta.31

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 (65) hide show
  1. package/README.md +6 -6
  2. package/dist/Adapter.d.mts +1 -1
  3. package/dist/Testing.d.mts +44 -0
  4. package/dist/Testing.mjs +310 -0
  5. package/dist/Testing.mjs.map +1 -0
  6. package/dist/internal/configuration.d.mts +18 -8
  7. package/dist/internal/configuration.mjs.map +1 -1
  8. package/dist/internal/models/oauth-model.d.mts +10 -1
  9. package/dist/internal/models/oauth-model.mjs.map +1 -1
  10. package/dist/internal/models/password-model.d.mts +2 -1
  11. package/dist/internal/models/password-model.mjs.map +1 -1
  12. package/dist/internal/oauth/native-accounts.mjs +42 -1
  13. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  14. package/dist/internal/oauth/native-state.d.mts +1 -0
  15. package/dist/internal/oauth/native-state.mjs +2 -2
  16. package/dist/internal/oauth/native-state.mjs.map +1 -1
  17. package/dist/internal/passkey-managed.mjs +4 -2
  18. package/dist/internal/passkey-managed.mjs.map +1 -1
  19. package/dist/internal/password-native.mjs +14 -0
  20. package/dist/internal/password-native.mjs.map +1 -1
  21. package/dist/internal/password-registration-native.d.mts +1 -1
  22. package/dist/internal/persistence.d.mts +3 -0
  23. package/dist/internal/persistence.mjs +4 -6
  24. package/dist/internal/persistence.mjs.map +1 -1
  25. package/dist/internal/registration.mjs +17 -8
  26. package/dist/internal/registration.mjs.map +1 -1
  27. package/dist/internal/session-native-authority.mjs +2 -1
  28. package/dist/internal/session-native-authority.mjs.map +1 -1
  29. package/dist/internal/session-native-login.mjs +2 -1
  30. package/dist/internal/session-native-login.mjs.map +1 -1
  31. package/dist/internal/session-native-pending-read.mjs +1 -1
  32. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  33. package/dist/internal/session-native-state.mjs +21 -8
  34. package/dist/internal/session-native-state.mjs.map +1 -1
  35. package/dist/internal/session-native-stateful.mjs +2 -1
  36. package/dist/internal/session-native-stateful.mjs.map +1 -1
  37. package/dist/internal/session-native-step-up.mjs +4 -2
  38. package/dist/internal/session-native-step-up.mjs.map +1 -1
  39. package/dist/internal/sql-change.mjs +11 -1
  40. package/dist/internal/sql-change.mjs.map +1 -1
  41. package/dist/internal/sqlite-policy-guard.mjs +23 -0
  42. package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
  43. package/dist/internal/storage-mapping.d.mts +9 -5
  44. package/dist/internal/storage-mapping.mjs +16 -4
  45. package/dist/internal/storage-mapping.mjs.map +1 -1
  46. package/package.json +1 -1
  47. package/src/Testing.ts +652 -0
  48. package/src/internal/configuration.ts +26 -8
  49. package/src/internal/models/oauth-model.ts +10 -0
  50. package/src/internal/models/password-model.ts +2 -1
  51. package/src/internal/oauth/native-accounts.ts +72 -1
  52. package/src/internal/oauth/native-state.ts +6 -2
  53. package/src/internal/passkey-managed.ts +8 -2
  54. package/src/internal/password-native.ts +28 -2
  55. package/src/internal/persistence.ts +174 -164
  56. package/src/internal/registration.ts +53 -40
  57. package/src/internal/session-native-authority.ts +3 -1
  58. package/src/internal/session-native-login.ts +3 -1
  59. package/src/internal/session-native-pending-read.ts +1 -1
  60. package/src/internal/session-native-state.ts +29 -10
  61. package/src/internal/session-native-stateful.ts +3 -1
  62. package/src/internal/session-native-step-up.ts +5 -2
  63. package/src/internal/sql-change.ts +14 -1
  64. package/src/internal/sqlite-policy-guard.ts +34 -0
  65. package/src/internal/storage-mapping.ts +40 -4
@@ -14,12 +14,13 @@ import {
14
14
  } from "@yielded/auth/Sessions";
15
15
  import { Cause, Effect, Schema, DateTime } from "effect";
16
16
  import { SqlClient } from "effect/sql/SqlClient";
17
- import type { Fragment } from "effect/sql/Statement";
17
+ import { fragment, parameter, type Fragment } from "effect/sql/Statement";
18
18
 
19
19
  import { PersistenceMappingError } from "./mapping-error";
20
20
  import type { SessionAuthorityTables, SessionExecution } from "./models/session-model";
21
- import type { NativeSqlTables, SqlTable } from "./native-sql-table";
21
+ import { nativeSqlAlias, type NativeSqlTables, type SqlTable } from "./native-sql-table";
22
22
  import { anySqlCondition, exactSqlText } from "./sql-change";
23
+ import { guardSqlitePolicy, type SqlitePolicyValue } from "./sqlite-policy-guard";
23
24
  import type { AnyTableModel } from "./table-model";
24
25
 
25
26
  export const sessionUnavailable = () => SessionUnavailable.make({});
@@ -72,6 +73,7 @@ export type NativeSessionAuthorityMapping = SessionAuthorityTables<
72
73
  export interface NativeSessionAuthority {
73
74
  readonly native: unknown;
74
75
  readonly row: Record<string, unknown>;
76
+ readonly policyValues: ReadonlyArray<SqlitePolicyValue>;
75
77
  readonly revision: AuthenticationRevision;
76
78
  readonly requirement: AuthenticationRequirement;
77
79
  readonly now: number;
@@ -133,13 +135,15 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
133
135
  const decode = Effect.fnUntraced(function* (
134
136
  subjectId: SubjectId,
135
137
  native: unknown,
136
- row: Record<string, unknown>,
138
+ projected: Record<string, unknown>,
137
139
  factors: ReadonlyArray<Record<string, unknown>>,
138
140
  instant: unknown,
139
141
  ): Effect.fn.Return<
140
142
  NativeSessionAuthority | undefined,
141
143
  PersistenceMappingError | Schema.SchemaError
142
144
  > {
145
+ const row = subject.decode(projected, "s_");
146
+
143
147
  sessionInvariant((yield* mapping.subjectId.toSubject(row[s.id])) === subjectId);
144
148
  if (!s.isActiveStatus(row[s.status])) return undefined;
145
149
  const credentials: { credentialId: string; revision: string }[] = [];
@@ -161,6 +165,16 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
161
165
  return {
162
166
  native,
163
167
  row,
168
+ // Compare the selected SQL representation, without round-tripping through
169
+ // application codecs (for example, JSON whitespace or date precision).
170
+ policyValues: batch
171
+ ? (s.requirementColumns ?? []).map((key) => ({
172
+ column: subject.selectedColumn(key),
173
+ value: fragment([
174
+ parameter(projected[nativeSqlAlias("s_", subject.keys.indexOf(key))]),
175
+ ]),
176
+ }))
177
+ : [],
164
178
  revision: yield* Schema.decodeUnknownEffect(AuthenticationRevision)({
165
179
  subjectId,
166
180
  securityRevision: row[s.securityRevision],
@@ -188,7 +202,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
188
202
  return yield* decode(
189
203
  subjectId,
190
204
  native,
191
- subject.decode(owners[0], "s_"),
205
+ owners[0],
192
206
  factors.map((row) => credential.decode(row, "c_")),
193
207
  owners[0].engine_now,
194
208
  );
@@ -202,18 +216,23 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
202
216
  return yield* decode(
203
217
  subjectId,
204
218
  native,
205
- joinedSubject.decode(rows[0], "s_"),
219
+ rows[0],
206
220
  rows.map((row) => joinedCredential.decode(row, "c_")),
207
221
  rows[0].engine_now,
208
222
  );
209
223
  });
210
224
 
211
- const policyCondition = (row: Record<string, unknown>, native: unknown): Fragment => {
212
- if (!batch) return sql`1 = 1`;
225
+ const guardPolicy = Effect.fnUntraced(function* (current: NativeSessionAuthority) {
226
+ if (!batch) return;
213
227
  sessionInvariant(s.requirementColumns !== undefined);
214
228
 
215
- return sql`exists(select 1 from ${subject.name} where ${id(subject, s.id, native)} and ${sql.and(s.requirementColumns.map((key) => (row[key] === null ? sql`${subject.column(key)} is null` : typeof row[key] === "string" ? exact(subject, key, row[key]) : id(subject, key, row[key]))))})`;
216
- };
229
+ yield* guardSqlitePolicy({
230
+ name: "session-subject-policy",
231
+ table: subject.name,
232
+ owner: id(subject, s.id, current.native),
233
+ values: current.policyValues,
234
+ });
235
+ });
217
236
 
218
237
  const authorityCondition = (revision: AuthenticationRevision, native: unknown): Fragment =>
219
238
  sql.and([
@@ -242,7 +261,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
242
261
  decode,
243
262
  read,
244
263
  authorityCondition,
245
- policyCondition,
264
+ guardPolicy,
246
265
  };
247
266
  });
248
267
 
@@ -261,7 +261,9 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
261
261
  };
262
262
 
263
263
  const deadline = Math.min(expires, assessment.validUntil, pendingExpiry);
264
- const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
264
+
265
+ yield* state.guardPolicy(current);
266
+ const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
265
267
 
266
268
  const values = {
267
269
  ...s.encodeInsert(record, { subjectId: current.native, sessionId: nativeSession }),
@@ -211,7 +211,8 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
211
211
  const intent = { ...input, version },
212
212
  snapshot = yield* encodeStepUpIntent(intent);
213
213
 
214
- const condition = sql`${state.authorityCondition(intent.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${original.condition} and ${now} >= ${DateTime.toEpochMillis(intent.sourceAuthenticatedAt)} and ${now} < ${DateTime.toEpochMillis(intent.expiresAt)}`;
214
+ yield* state.guardPolicy(current);
215
+ const condition = sql`${state.authorityCondition(intent.revision, current.native)} and ${original.condition} and ${now} >= ${DateTime.toEpochMillis(intent.sourceAuthenticatedAt)} and ${now} < ${DateTime.toEpochMillis(intent.expiresAt)}`;
215
216
 
216
217
  yield* pending
217
218
  .insert(
@@ -340,7 +341,9 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
340
341
  )
341
342
  return yield* SessionStepUpInvalid.make({});
342
343
  const snapshot = yield* encodeStepUpIntent(intent);
343
- const authority = sql`${state.authorityCondition(evidence.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
344
+
345
+ yield* state.guardPolicy(current);
346
+ const authority = sql`${state.authorityCondition(evidence.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
344
347
 
345
348
  yield* pending.consume(
346
349
  "StepUp",
@@ -1,5 +1,5 @@
1
1
  import { Effect, Schema } from "effect";
2
- import type { SqlClient } from "effect/sql/SqlClient";
2
+ import { SqlClient } from "effect/sql/SqlClient";
3
3
  import type { SqlError } from "effect/sql/SqlError";
4
4
  import { join, type Fragment } from "effect/sql/Statement";
5
5
 
@@ -25,6 +25,19 @@ export const anySqlCondition: (conditions: ReadonlyArray<Fragment>) => Fragment
25
25
  "1 = 0",
26
26
  );
27
27
 
28
+ /** Exact text comparison and ordering, independent of the column's collation. */
29
+ export const sqlTextBytes = Effect.fnUntraced(function* (
30
+ value: Fragment,
31
+ ): Effect.fn.Return<Fragment, never, SqlClient> {
32
+ const sql = (yield* SqlClient).withoutTransforms();
33
+
34
+ return sql.onDialectOrElse({
35
+ mysql: () => sql`binary ${value}`,
36
+ pg: () => sql`convert_to(cast(${value} as text), 'UTF8')`,
37
+ orElse: () => sql`cast(${value} as blob)`,
38
+ });
39
+ });
40
+
28
41
  /** Keep the indexed predicate and reject collation aliases of security tokens. */
29
42
  export const exactSqlText = (sql: SqlClient, left: Fragment, right: Fragment) =>
30
43
  sql.and([
@@ -0,0 +1,34 @@
1
+ import { Array, Effect } from "effect";
2
+ import { SqlClient } from "effect/sql/SqlClient";
3
+ import type { Fragment } from "effect/sql/Statement";
4
+
5
+ import { appendSqlBatchStatement, registerSqlBatchPostcondition } from "./sql-commit";
6
+
7
+ export interface SqlitePolicyValue {
8
+ readonly column: Fragment;
9
+ readonly value: Fragment;
10
+ }
11
+
12
+ /** Check original values before writes and at the end of the same atomic batch.
13
+ * Small groups bound parameters and expression depth without expanding retained
14
+ * values into hex/JSON. BINARY preserves text bytes; IS also compares NULLs. */
15
+ export const guardSqlitePolicy = Effect.fnUntraced(function* (input: {
16
+ readonly name: string;
17
+ readonly table: Fragment;
18
+ readonly owner: Fragment;
19
+ readonly values: ReadonlyArray<SqlitePolicyValue>;
20
+ }) {
21
+ const sql = (yield* SqlClient).withoutTransforms();
22
+
23
+ for (const values of Array.chunksOf(input.values, 32)) {
24
+ const matches = sql.and(
25
+ values.map(({ column, value }) => sql`${column} collate binary is ${value}`),
26
+ );
27
+
28
+ const condition = sql`exists(select 1 from ${input.table} where ${input.owner} and ${matches})`;
29
+ const statement = sql`select case when ${condition} then 1 else json_extract('[]', '$[auth-batch-precondition]') end as valid`;
30
+
31
+ yield* appendSqlBatchStatement(statement);
32
+ yield* registerSqlBatchPostcondition({ name: input.name, statement });
33
+ }
34
+ });
@@ -16,6 +16,7 @@ import {
16
16
  type StatefulSessionRecord,
17
17
  } from "@yielded/auth/Sessions";
18
18
  import { Crypto, DateTime, Effect, Redacted, Schema, Struct } from "effect";
19
+ import { SqlClient } from "effect/sql";
19
20
 
20
21
  import type { MappingInput } from "./configuration";
21
22
  import { randomId } from "./crypto";
@@ -27,6 +28,8 @@ import {
27
28
  } from "./models/password-model";
28
29
  import { requiredProofConstraints, type AnyProofPersistenceMapping } from "./models/proof-model";
29
30
  import type { StatefulSessionMapping, PendingAuthenticationMapping } from "./models/session-model";
31
+ import type { NativeSqlTables } from "./native-sql-table";
32
+ import { exactSqlText } from "./sql-change";
30
33
  import { makeStorageClock } from "./storage-clock";
31
34
  import { storageTables, type StorageRole } from "./storage-tables";
32
35
  import type { TableModel, SqlExpression } from "./table-model";
@@ -40,12 +43,16 @@ const string = (value: unknown) => Schema.decodeUnknownSync(Schema.String)(value
40
43
 
41
44
  /** Derive shared row mappings from a managed or custom storage layout.
42
45
  * Acquire Effect Crypto once; the returned allocators retain that implementation.
43
- * Each factory requires its role tables to exist. Adapter authors supply typed
44
- * table handles and dialect-specific clocks/commit predicates, and may refine
45
- * authority policy for explicit services. This does not alter composed defaults.
46
+ * Each factory requires its role tables to exist. Supplying native tables derives
47
+ * batch commit predicates and subject policy columns. Adapter authors may refine
48
+ * clocks and authority policy for explicit services.
46
49
  */
47
- export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
50
+ export const makeMappings = Effect.fnUntraced(function* (
51
+ input: MappingInput,
52
+ tables?: NativeSqlTables,
53
+ ) {
48
54
  const crypto = yield* Crypto.Crypto;
55
+ const sql = (yield* SqlClient.SqlClient).withoutTransforms();
49
56
 
50
57
  const allocate = randomId.pipe(
51
58
  Effect.provideService(Crypto.Crypto, crypto),
@@ -67,12 +74,27 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
67
74
 
68
75
  const { subjects: s, encodeInstant: instant, decodeInstant: readInstant } = input;
69
76
  const subjectId = { toNative: s.toNative, toSubject: s.toSubject, equals: Object.is };
77
+ const identifierTable = tables?.(table("identifiers"));
78
+
79
+ const activeIdentifier =
80
+ identifierTable === undefined
81
+ ? undefined
82
+ : sql`${identifierTable.column("active")} = ${identifierTable.value("active", true)}`;
70
83
 
71
84
  const subject = {
72
85
  table: s.table,
73
86
  id: s.id,
74
87
  status: s.status,
75
88
  securityRevision: s.securityRevision,
89
+ // Policy callbacks may read any application column. Identity, status and
90
+ // security revision have their own final checks and can be intentionally revised.
91
+ ...(tables === undefined
92
+ ? {}
93
+ : {
94
+ requirementColumns: tables(s.table).keys.filter(
95
+ (key) => key !== s.id && key !== s.status && key !== s.securityRevision,
96
+ ),
97
+ }),
76
98
  isActiveStatus: (value: unknown) => Object.is(value, s.activeValue),
77
99
  d1ActiveStatusValue: s.activeValue,
78
100
  activeStatusValue: s.activeValue,
@@ -88,6 +110,7 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
88
110
  revision: "revision",
89
111
  status: "active",
90
112
  activeStatusValue: true,
113
+ d1ActiveStatusValue: true,
91
114
  isActiveStatus: (value: unknown) => value === true,
92
115
  encodeInsert: (row: object) => ({ ...row, active: true }),
93
116
  encodeRevision: (revision: SecurityRevision) => ({ revision }),
@@ -142,6 +165,11 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
142
165
  verifiedAt: "verifiedAt",
143
166
  bindingRevision: "revision",
144
167
  isCurrent: (row) => row.active === true,
168
+ ...(activeIdentifier === undefined
169
+ ? {}
170
+ : {
171
+ d1CurrentCondition: () => activeIdentifier,
172
+ }),
145
173
  encodeInitialInsert: (identifier, subjectId, revision) => ({
146
174
  ...identifier,
147
175
  subjectId,
@@ -230,6 +258,14 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
230
258
  bindingRevision: "revision",
231
259
  isCurrent: (row) => row.active === true && row.verifiedAt !== null,
232
260
  isMutableTarget: (row) => row.active === true && row.verifiedAt === null,
261
+ ...(identifierTable === undefined || activeIdentifier === undefined
262
+ ? {}
263
+ : {
264
+ d1CurrentCondition: ({ bindingRevision }) =>
265
+ sql`${activeIdentifier} and ${identifierTable.column("verifiedAt")} is not null and ${exactSqlText(sql, identifierTable.column("revision"), identifierTable.value("revision", bindingRevision))}`,
266
+ d1MutableTargetCondition: () =>
267
+ sql`${activeIdentifier} and ${identifierTable.column("verifiedAt")} is null`,
268
+ }),
233
269
  encodeVerifiedInsert: ({ identifier, subjectId, verifiedAtMillis, bindingRevision }) => ({
234
270
  ...identifier,
235
271
  subjectId,