@yielded/auth-persistence 0.1.0-beta.30 → 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.
- package/README.md +3 -6
- package/dist/Adapter.d.mts +1 -1
- package/dist/internal/configuration.d.mts +18 -8
- package/dist/internal/configuration.mjs.map +1 -1
- package/dist/internal/models/password-model.d.mts +2 -1
- package/dist/internal/models/password-model.mjs.map +1 -1
- package/dist/internal/passkey-managed.mjs +4 -2
- package/dist/internal/passkey-managed.mjs.map +1 -1
- package/dist/internal/password-native.mjs +14 -0
- package/dist/internal/password-native.mjs.map +1 -1
- package/dist/internal/password-registration-native.d.mts +1 -1
- package/dist/internal/persistence.d.mts +3 -0
- package/dist/internal/persistence.mjs +4 -6
- package/dist/internal/persistence.mjs.map +1 -1
- package/dist/internal/registration.mjs +17 -8
- package/dist/internal/registration.mjs.map +1 -1
- package/dist/internal/session-native-authority.mjs +2 -1
- package/dist/internal/session-native-authority.mjs.map +1 -1
- package/dist/internal/session-native-login.mjs +2 -1
- package/dist/internal/session-native-login.mjs.map +1 -1
- package/dist/internal/session-native-pending-read.mjs +1 -1
- package/dist/internal/session-native-pending-read.mjs.map +1 -1
- package/dist/internal/session-native-state.mjs +21 -8
- package/dist/internal/session-native-state.mjs.map +1 -1
- package/dist/internal/session-native-stateful.mjs +2 -1
- package/dist/internal/session-native-stateful.mjs.map +1 -1
- package/dist/internal/session-native-step-up.mjs +4 -2
- package/dist/internal/session-native-step-up.mjs.map +1 -1
- package/dist/internal/sqlite-policy-guard.mjs +23 -0
- package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
- package/dist/internal/storage-mapping.d.mts +9 -5
- package/dist/internal/storage-mapping.mjs +16 -4
- package/dist/internal/storage-mapping.mjs.map +1 -1
- package/package.json +1 -1
- package/src/internal/configuration.ts +26 -8
- package/src/internal/models/password-model.ts +2 -1
- package/src/internal/passkey-managed.ts +8 -2
- package/src/internal/password-native.ts +28 -2
- package/src/internal/persistence.ts +174 -164
- package/src/internal/registration.ts +53 -40
- package/src/internal/session-native-authority.ts +3 -1
- package/src/internal/session-native-login.ts +3 -1
- package/src/internal/session-native-pending-read.ts +1 -1
- package/src/internal/session-native-state.ts +29 -10
- package/src/internal/session-native-stateful.ts +3 -1
- package/src/internal/session-native-step-up.ts +5 -2
- package/src/internal/sqlite-policy-guard.ts +34 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
212
|
-
if (!batch) return
|
|
225
|
+
const guardPolicy = Effect.fnUntraced(function* (current: NativeSessionAuthority) {
|
|
226
|
+
if (!batch) return;
|
|
213
227
|
sessionInvariant(s.requirementColumns !== undefined);
|
|
214
228
|
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
@@ -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.
|
|
44
|
-
*
|
|
45
|
-
* authority policy for explicit services.
|
|
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* (
|
|
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,
|