@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.
- package/README.md +6 -6
- package/dist/Adapter.d.mts +1 -1
- package/dist/Testing.d.mts +44 -0
- package/dist/Testing.mjs +310 -0
- package/dist/Testing.mjs.map +1 -0
- package/dist/internal/configuration.d.mts +18 -8
- package/dist/internal/configuration.mjs.map +1 -1
- package/dist/internal/models/oauth-model.d.mts +10 -1
- package/dist/internal/models/oauth-model.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/oauth/native-accounts.mjs +42 -1
- package/dist/internal/oauth/native-accounts.mjs.map +1 -1
- package/dist/internal/oauth/native-state.d.mts +1 -0
- package/dist/internal/oauth/native-state.mjs +2 -2
- package/dist/internal/oauth/native-state.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/sql-change.mjs +11 -1
- package/dist/internal/sql-change.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/Testing.ts +652 -0
- package/src/internal/configuration.ts +26 -8
- package/src/internal/models/oauth-model.ts +10 -0
- package/src/internal/models/password-model.ts +2 -1
- package/src/internal/oauth/native-accounts.ts +72 -1
- package/src/internal/oauth/native-state.ts +6 -2
- 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/sql-change.ts +14 -1
- package/src/internal/sqlite-policy-guard.ts +34 -0
- package/src/internal/storage-mapping.ts +40 -4
|
@@ -97,6 +97,27 @@ export interface ProvisioningId<Id extends string> {
|
|
|
97
97
|
readonly service: "PersistenceProvisioning";
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
export interface SubjectProvisioningInput<Registration> {
|
|
101
|
+
readonly requestId: string;
|
|
102
|
+
readonly identifier: LoginIdentifier;
|
|
103
|
+
readonly registration: Registration;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Return application subject values for insertion by the auth commit owner.
|
|
107
|
+
* Include the allocated native ID, active status, and initial security revision.
|
|
108
|
+
* Preparing values may allocate IDs, but must not write to the database or perform
|
|
109
|
+
* external side effects. Interactive drivers also accept an existing insert callback.
|
|
110
|
+
*/
|
|
111
|
+
export type SubjectProvisioning<Registration> =
|
|
112
|
+
| {
|
|
113
|
+
readonly values: (
|
|
114
|
+
input: SubjectProvisioningInput<Registration>,
|
|
115
|
+
) => Effect.Effect<Readonly<Record<string, unknown>>, PasswordUnavailable>;
|
|
116
|
+
}
|
|
117
|
+
| ((
|
|
118
|
+
input: SubjectProvisioningInput<Registration>,
|
|
119
|
+
) => Effect.Effect<SubjectId, PasswordUnavailable>);
|
|
120
|
+
|
|
100
121
|
export type Provisioning<A extends { readonly strategies: Readonly<Record<string, Strategy>> }> = {
|
|
101
122
|
readonly [
|
|
102
123
|
K in keyof A["strategies"] as A["strategies"][K] extends {
|
|
@@ -104,11 +125,7 @@ export type Provisioning<A extends { readonly strategies: Readonly<Record<string
|
|
|
104
125
|
}
|
|
105
126
|
? K
|
|
106
127
|
: never
|
|
107
|
-
]:
|
|
108
|
-
readonly requestId: string;
|
|
109
|
-
readonly identifier: LoginIdentifier;
|
|
110
|
-
readonly registration: Registration<A["strategies"][K]>;
|
|
111
|
-
}) => Effect.Effect<SubjectId, PasswordUnavailable>;
|
|
128
|
+
]: SubjectProvisioning<Registration<A["strategies"][K]>>;
|
|
112
129
|
};
|
|
113
130
|
|
|
114
131
|
export type ProvisioningRequirement<
|
|
@@ -217,9 +234,10 @@ export interface BoundPersistence<
|
|
|
217
234
|
value: StorageLayout<T, Roles<C, Id, A>>,
|
|
218
235
|
) => Layer.Layer<ConfigId<A["namespace"]>>;
|
|
219
236
|
};
|
|
220
|
-
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
237
|
+
/** Supply subject values through `{ values }` on every driver, including D1.
|
|
238
|
+
* Interactive drivers also accept callbacks that insert through the owning
|
|
239
|
+
* SqlClient and return the new subject ID. Never open an independent transaction
|
|
240
|
+
* or perform external side effects while preparing a subject.
|
|
223
241
|
*/
|
|
224
242
|
readonly Provisioning: Context.Service<ProvisioningRequirement<A>, Provisioning<A>>;
|
|
225
243
|
/** Capture dependencies at acquisition; validate SQL metadata and configure storage
|
|
@@ -2,6 +2,7 @@ import type { PreparedCommit } from "@yielded/auth/Hooks";
|
|
|
2
2
|
import type {
|
|
3
3
|
OAuthActionAuthorization,
|
|
4
4
|
OAuthAccountRevision,
|
|
5
|
+
OAuthLinkedAccountsRead,
|
|
5
6
|
OAuthRegistrationIntent,
|
|
6
7
|
OAuthExternalIdentity,
|
|
7
8
|
OAuthRegistrationAccess,
|
|
@@ -442,6 +443,15 @@ export interface OAuthAccountsMapping<
|
|
|
442
443
|
readonly subjectId: SubjectIdCodec<N>;
|
|
443
444
|
readonly clock: OAuthClock<Expression>;
|
|
444
445
|
readonly constraints: typeof requiredOAuthSignInConstraints;
|
|
446
|
+
/** Self-contained SQL predicate rechecking this invocation's current permission
|
|
447
|
+
* to view login metadata. Evaluated in every releasing read, alongside active
|
|
448
|
+
* subject/credential authority and ownership. Do not trust public assurance as
|
|
449
|
+
* credential provenance; bind session policy to application-owned rows as needed. */
|
|
450
|
+
readonly metadataAccess: (input: {
|
|
451
|
+
readonly invocation: OAuthLinkedAccountsRead["invocation"];
|
|
452
|
+
readonly moduleId: string;
|
|
453
|
+
readonly subjectId: N;
|
|
454
|
+
}) => Expression;
|
|
445
455
|
readonly eligibility: ReadonlyArray<OAuthEligibilityDescriptor<N, Expression, Descriptor>>;
|
|
446
456
|
readonly cleanup: ReadonlyArray<OAuthCleanupDescriptor<N, Expression, Descriptor>>;
|
|
447
457
|
readonly sessionInvalidation: "same-authority-immediate" | "original-absolute-expiry";
|
|
@@ -23,7 +23,8 @@ export interface PasswordSubjectTable<Subject extends Table, _NativeSubjectId> {
|
|
|
23
23
|
readonly decodeRequirement: (
|
|
24
24
|
row: Subject["select"],
|
|
25
25
|
) => Effect.Effect<AuthenticationRequirement, PersistenceMappingError>;
|
|
26
|
-
/** Current policy for this exact credential mutation
|
|
26
|
+
/** Current policy for this exact credential mutation. Interactive drivers hold
|
|
27
|
+
* the subject lock; D1 guards the physical application columns through commit. */
|
|
27
28
|
readonly decodeActionRequirement: (
|
|
28
29
|
row: Subject["select"],
|
|
29
30
|
action: PasswordAction,
|
|
@@ -8,7 +8,7 @@ import type { Fragment } from "effect/sql/Statement";
|
|
|
8
8
|
import { type OAuthAccountsMapping, OAuthEligibilityFact } from "../models/oauth-model";
|
|
9
9
|
import type { NativeSqlTables } from "../native-sql-table";
|
|
10
10
|
import { makeConditionalSqlInsert } from "../session-native-record";
|
|
11
|
-
import { anySqlCondition, exactSqlText } from "../sql-change";
|
|
11
|
+
import { anySqlCondition, exactSqlText, sqlTextBytes } from "../sql-change";
|
|
12
12
|
import { makeSqlCommitExecutor, SqlBatchCommit, CurrentSqlCommit } from "../sql-commit";
|
|
13
13
|
import type { SqlExpression, TableModel } from "../table-model";
|
|
14
14
|
import { makeOAuthNativeFlow } from "./native-flow";
|
|
@@ -86,6 +86,77 @@ export const makeNativeOAuthAccountsServices = Effect.fnUntraced(function* (
|
|
|
86
86
|
]);
|
|
87
87
|
|
|
88
88
|
const service: M.OAuthAccountsPersistence["Service"] = {
|
|
89
|
+
list: (original) =>
|
|
90
|
+
read(
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const input = M.snapshotOAuthSync(M.OAuthLinkedAccountsRead, original);
|
|
93
|
+
const nativeId = yield* mapping.subjectId.toNative(input.invocation.subjectId);
|
|
94
|
+
|
|
95
|
+
const metadataAccess = tables.expression(
|
|
96
|
+
mapping.metadataAccess({
|
|
97
|
+
invocation: input.invocation,
|
|
98
|
+
moduleId: input.moduleId,
|
|
99
|
+
subjectId: nativeId,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const s = mapping.subject;
|
|
104
|
+
const orderedId = yield* sqlTextBytes(credential.column(c.credentialId));
|
|
105
|
+
|
|
106
|
+
const cursorCondition =
|
|
107
|
+
input.cursor === undefined
|
|
108
|
+
? sql``
|
|
109
|
+
: sql`and ${orderedId} > ${yield* sqlTextBytes(credential.value(c.credentialId, input.cursor))}`;
|
|
110
|
+
|
|
111
|
+
// Bound candidate work before resolving identities. Each final read uses
|
|
112
|
+
// its columns' own codecs and rechecks live ownership/authority/policy.
|
|
113
|
+
// A removed candidate can leave a short page; the cursor still advances.
|
|
114
|
+
// Match exact credential identity even when the column's collation does not.
|
|
115
|
+
const rows = yield* sql`select ${credential.fields("page_")} from ${credential.name}
|
|
116
|
+
where ${exactSqlText(sql, credential.column(c.moduleId), credential.value(c.moduleId, input.moduleId))}
|
|
117
|
+
and ${credential.column(c.subjectId)} = ${credential.value(c.subjectId, nativeId)}
|
|
118
|
+
and ${tables.expression(c.activeCondition)} and ${metadataAccess}
|
|
119
|
+
and exists(select 1 from ${state.subject.name}
|
|
120
|
+
where ${state.subject.column(s.id)} = ${state.subject.value(s.id, nativeId)}
|
|
121
|
+
and ${tables.expression(s.activeCondition)})
|
|
122
|
+
${cursorCondition}
|
|
123
|
+
order by ${orderedId} limit ${input.limit + 1}`;
|
|
124
|
+
|
|
125
|
+
const selected = rows.slice(0, input.limit);
|
|
126
|
+
const items: Array<M.OAuthLinkedAccount> = [];
|
|
127
|
+
let cursor: string | undefined;
|
|
128
|
+
|
|
129
|
+
for (const row of selected) {
|
|
130
|
+
const credentialId = yield* Schema.decodeUnknownEffect(
|
|
131
|
+
M.OAuthLinkedAccount.fields.credentialId,
|
|
132
|
+
)(credential.decode(row, "page_")[c.credentialId]);
|
|
133
|
+
|
|
134
|
+
cursor = credentialId;
|
|
135
|
+
|
|
136
|
+
const current = yield* state.readCredential(
|
|
137
|
+
{
|
|
138
|
+
moduleId: input.moduleId,
|
|
139
|
+
subjectId: input.invocation.subjectId,
|
|
140
|
+
credentialId,
|
|
141
|
+
},
|
|
142
|
+
metadataAccess,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (current !== undefined)
|
|
146
|
+
items.push(
|
|
147
|
+
M.OAuthLinkedAccount.make({
|
|
148
|
+
credentialId: current.credentialId,
|
|
149
|
+
...current.identity,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return M.snapshotOAuthSync(M.OAuthLinkedAccountsListResult, {
|
|
155
|
+
items,
|
|
156
|
+
...(rows.length > input.limit && cursor !== undefined ? { cursor } : {}),
|
|
157
|
+
});
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
89
160
|
capture: (input) =>
|
|
90
161
|
read(Effect.map(state.capture(input.subjectId, false), (current) => current?.revision)),
|
|
91
162
|
issue: (original, prepare) =>
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import type { SubjectId } from "@yielded/auth/Schema";
|
|
8
8
|
import { Effect, Schema } from "effect";
|
|
9
9
|
import { SqlClient } from "effect/sql";
|
|
10
|
+
import type { Fragment } from "effect/sql/Statement";
|
|
10
11
|
|
|
11
12
|
import type { OAuthSignInMapping } from "../models/oauth-model";
|
|
12
13
|
import type { NativeSqlTables } from "../native-sql-table";
|
|
@@ -305,7 +306,10 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
|
|
|
305
306
|
return result;
|
|
306
307
|
});
|
|
307
308
|
|
|
308
|
-
const readCredential = Effect.fnUntraced(function* (
|
|
309
|
+
const readCredential = Effect.fnUntraced(function* (
|
|
310
|
+
input: typeof OAuthCredentialKey.Type,
|
|
311
|
+
metadataAccess: Fragment = sql`true`,
|
|
312
|
+
) {
|
|
309
313
|
const nativeId = yield* mapping.subjectId.toNative(input.subjectId);
|
|
310
314
|
let identityKey: unknown;
|
|
311
315
|
|
|
@@ -334,7 +338,7 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
|
|
|
334
338
|
and ${owner(o, mapping.ownership.subjectId)}
|
|
335
339
|
join ${active("subject", "oauth_subject")} on ${owner(s, mapping.subject.id)}
|
|
336
340
|
join ${active("authority", "oauth_authority")} on ${owner(a, mapping.authority.subjectId)}
|
|
337
|
-
where ${owner(c, mapping.credential.subjectId)}
|
|
341
|
+
where ${metadataAccess} and ${owner(c, mapping.credential.subjectId)}
|
|
338
342
|
and ${exact(c.column(mapping.credential.moduleId), c.value(mapping.credential.moduleId, input.moduleId))}
|
|
339
343
|
and ${exact(c.column(mapping.credential.credentialId), c.value(mapping.credential.credentialId, input.credentialId))}
|
|
340
344
|
order by ${a.column(mapping.authority.credentialId)} limit 65`;
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -6,13 +6,13 @@ import type { SubjectId } from "@yielded/auth/Schema";
|
|
|
6
6
|
import { SecurityRevision, type AuthenticationRequirement } from "@yielded/auth/Sessions";
|
|
7
7
|
import { DateTime, Effect, Option, Redacted, Schema, Crypto } from "effect";
|
|
8
8
|
import { SqlClient } from "effect/sql/SqlClient";
|
|
9
|
-
import type
|
|
9
|
+
import { fragment, parameter, type Fragment } from "effect/sql/Statement";
|
|
10
10
|
|
|
11
11
|
import { sqlBatchAssertion } from "./d1-planning";
|
|
12
12
|
import { PersistenceMappingError } from "./mapping-error";
|
|
13
13
|
import type { AnyPasswordPersistenceMapping } from "./models/password-model";
|
|
14
14
|
import type { AnyProofPersistenceMapping } from "./models/proof-model";
|
|
15
|
-
import type
|
|
15
|
+
import { nativeSqlAlias, type NativeSqlTables, type SqlTable } from "./native-sql-table";
|
|
16
16
|
import { makePasswordCredentials, samePasswordCredentialSnapshot } from "./password-credentials";
|
|
17
17
|
import {
|
|
18
18
|
allocatePasswordNextSecurityRevision,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
registerSqlPostcondition,
|
|
34
34
|
SqlBatchCommit,
|
|
35
35
|
} from "./sql-commit";
|
|
36
|
+
import { guardSqlitePolicy } from "./sqlite-policy-guard";
|
|
36
37
|
import { requireStandalone } from "./standalone";
|
|
37
38
|
|
|
38
39
|
const unavailable = () => P.PasswordUnavailable.make({});
|
|
@@ -70,6 +71,12 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
70
71
|
rawPassword = tables(c.table),
|
|
71
72
|
rawAuthority = tables(a.table);
|
|
72
73
|
|
|
74
|
+
// Action policy may inspect any application column. Authority columns have
|
|
75
|
+
// separate checks and securityRevision intentionally changes in this mutation.
|
|
76
|
+
const policyColumns = rawSubject.keys.filter(
|
|
77
|
+
(key) => key !== s.id && key !== s.status && key !== s.securityRevision,
|
|
78
|
+
);
|
|
79
|
+
|
|
73
80
|
const now = tables.expression(mapping.clock.engineNowMillis);
|
|
74
81
|
const lock = sql.onDialectOrElse({ sqlite: () => sql``, orElse: () => sql`for update` });
|
|
75
82
|
|
|
@@ -193,6 +200,17 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
193
200
|
return {
|
|
194
201
|
nativeId,
|
|
195
202
|
subjectRow,
|
|
203
|
+
policyValues:
|
|
204
|
+
batch === undefined
|
|
205
|
+
? []
|
|
206
|
+
: policyColumns.map((key) => ({
|
|
207
|
+
column: rawSubject.selectedColumn(key),
|
|
208
|
+
value: fragment([
|
|
209
|
+
parameter(
|
|
210
|
+
rows[0][nativeSqlAlias("password_subject_", rawSubject.keys.indexOf(key))],
|
|
211
|
+
),
|
|
212
|
+
]),
|
|
213
|
+
})),
|
|
196
214
|
identifierRow,
|
|
197
215
|
passwordRow,
|
|
198
216
|
revision,
|
|
@@ -344,6 +362,14 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
344
362
|
);
|
|
345
363
|
};
|
|
346
364
|
|
|
365
|
+
if (batch !== undefined)
|
|
366
|
+
yield* guardSqlitePolicy({
|
|
367
|
+
name: "password-subject-policy",
|
|
368
|
+
table: rawSubject.name,
|
|
369
|
+
owner: subjectKey(rawSubject, s.id, nativeId),
|
|
370
|
+
values: state.policyValues,
|
|
371
|
+
});
|
|
372
|
+
|
|
347
373
|
const requestedDeadline = freshUntil(input.authorization.requirement),
|
|
348
374
|
currentDeadline = freshUntil(requirement);
|
|
349
375
|
|