@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.
- package/README.md +11 -7
- package/dist/Adapter.d.mts +1 -1
- package/dist/Adapter.mjs +8 -8
- package/dist/OAuthPersistence.mjs +10 -5
- package/dist/OAuthPersistence.mjs.map +1 -1
- package/dist/internal/configuration.d.mts +49 -13
- package/dist/internal/configuration.mjs.map +1 -1
- package/dist/internal/email-native.mjs +10 -2
- package/dist/internal/email-native.mjs.map +1 -1
- package/dist/internal/email-policy.mjs +3 -2
- package/dist/internal/email-policy.mjs.map +1 -1
- package/dist/internal/models/oauth-model.d.mts +4 -2
- 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.d.mts +1 -1
- package/dist/internal/oauth/native-accounts.mjs +6 -6
- package/dist/internal/oauth/native-accounts.mjs.map +1 -1
- package/dist/internal/oauth/native-connected.d.mts +1 -1
- package/dist/internal/oauth/native-connected.mjs +1 -1
- package/dist/internal/oauth/native-registration.mjs +19 -8
- package/dist/internal/oauth/native-registration.mjs.map +1 -1
- package/dist/internal/oauth/native-sign-in.d.mts +1 -1
- package/dist/internal/oauth/native-sign-in.mjs +1 -1
- package/dist/internal/oauth/native-state.mjs +21 -11
- package/dist/internal/oauth/native-state.mjs.map +1 -1
- package/dist/internal/oauth/state.d.mts +1 -1
- package/dist/internal/oauth/state.mjs +1 -1
- package/dist/internal/oauth-managed.mjs +182 -0
- package/dist/internal/oauth-managed.mjs.map +1 -0
- package/dist/internal/passkey-managed.mjs +5 -3
- package/dist/internal/passkey-managed.mjs.map +1 -1
- package/dist/internal/passkey-native-registration.mjs +1 -1
- package/dist/internal/passkey-native-state.mjs +33 -23
- package/dist/internal/passkey-native-state.mjs.map +1 -1
- package/dist/internal/passkey-native-write.mjs +3 -3
- package/dist/internal/passkey-native-write.mjs.map +1 -1
- package/dist/internal/passkey-native.d.mts +15 -7
- package/dist/internal/passkey-native.mjs +2 -1
- package/dist/internal/passkey-native.mjs.map +1 -1
- package/dist/internal/passkey-policy.mjs +9 -7
- package/dist/internal/passkey-policy.mjs.map +1 -1
- package/dist/internal/password-native.mjs +22 -5
- package/dist/internal/password-native.mjs.map +1 -1
- package/dist/internal/password-policy.mjs +7 -6
- package/dist/internal/password-policy.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 +99 -42
- package/dist/internal/persistence.mjs.map +1 -1
- package/dist/internal/phone-native.mjs +1 -1
- package/dist/internal/phone-native.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 +8 -6
- package/dist/internal/session-native-authority.mjs.map +1 -1
- package/dist/internal/session-native-login.d.mts +2 -2
- package/dist/internal/session-native-login.mjs +4 -2
- package/dist/internal/session-native-login.mjs.map +1 -1
- package/dist/internal/session-native-pending-read.mjs +26 -20
- package/dist/internal/session-native-pending-read.mjs.map +1 -1
- package/dist/internal/session-native-record.mjs +6 -4
- package/dist/internal/session-native-record.mjs.map +1 -1
- package/dist/internal/session-native-state.mjs +24 -8
- package/dist/internal/session-native-state.mjs.map +1 -1
- package/dist/internal/session-native-stateful.d.mts +2 -2
- package/dist/internal/session-native-stateful.mjs +67 -38
- package/dist/internal/session-native-stateful.mjs.map +1 -1
- package/dist/internal/session-native-step-up.d.mts +2 -2
- package/dist/internal/session-native-step-up.mjs +17 -19
- package/dist/internal/session-native-step-up.mjs.map +1 -1
- package/dist/internal/session-native-validity.mjs +3 -1
- package/dist/internal/session-native-validity.mjs.map +1 -1
- package/dist/internal/sql-authority.mjs +5 -1
- package/dist/internal/sql-authority.mjs.map +1 -1
- package/dist/internal/sql-storage-validation.mjs +2 -1
- package/dist/internal/sql-storage-validation.mjs.map +1 -1
- package/dist/internal/sql-table.d.mts +1 -0
- package/dist/internal/sql-table.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/step-up-state.mjs +19 -6
- package/dist/internal/step-up-state.mjs.map +1 -1
- package/dist/internal/storage-mapping.d.mts +9 -5
- package/dist/internal/storage-mapping.mjs +17 -5
- package/dist/internal/storage-mapping.mjs.map +1 -1
- package/dist/internal/storage-tables.d.mts +237 -0
- package/dist/internal/storage-tables.mjs +68 -0
- package/dist/internal/storage-tables.mjs.map +1 -1
- package/dist/internal/storage-validation.d.mts +3 -1
- package/dist/internal/storage-validation.mjs +70 -17
- package/dist/internal/storage-validation.mjs.map +1 -1
- package/dist/internal/totp-native.mjs +1 -1
- package/package.json +1 -1
- package/src/OAuthPersistence.ts +7 -2
- package/src/internal/configuration.ts +72 -15
- package/src/internal/email-native.ts +16 -6
- package/src/internal/email-policy.ts +12 -3
- package/src/internal/models/oauth-model.ts +7 -1
- package/src/internal/models/password-model.ts +2 -1
- package/src/internal/oauth/native-accounts.ts +3 -3
- package/src/internal/oauth/native-registration.ts +17 -6
- package/src/internal/oauth/native-state.ts +38 -18
- package/src/internal/oauth-managed.ts +277 -0
- package/src/internal/passkey-managed.ts +8 -2
- package/src/internal/passkey-native-state.ts +59 -52
- package/src/internal/passkey-native-write.ts +2 -2
- package/src/internal/passkey-native.ts +4 -2
- package/src/internal/passkey-policy.ts +13 -9
- package/src/internal/password-native.ts +45 -11
- package/src/internal/password-policy.ts +16 -8
- package/src/internal/persistence.ts +360 -204
- package/src/internal/phone-native.ts +1 -1
- package/src/internal/registration.ts +53 -40
- package/src/internal/session-native-authority.ts +26 -8
- package/src/internal/session-native-login.ts +9 -4
- package/src/internal/session-native-pending-read.ts +46 -33
- package/src/internal/session-native-record.ts +6 -4
- package/src/internal/session-native-state.ts +32 -10
- package/src/internal/session-native-stateful.ts +147 -52
- package/src/internal/session-native-step-up.ts +31 -24
- package/src/internal/session-native-validity.ts +3 -1
- package/src/internal/sql-authority.ts +5 -1
- package/src/internal/sql-storage-validation.ts +53 -46
- package/src/internal/sql-table.ts +1 -0
- package/src/internal/sqlite-policy-guard.ts +34 -0
- package/src/internal/step-up-state.ts +43 -6
- package/src/internal/storage-mapping.ts +40 -4
- package/src/internal/storage-tables.ts +76 -0
- package/src/internal/storage-validation.ts +127 -27
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LifecycleHooks } from "@yielded/auth/Hooks";
|
|
2
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
2
3
|
import {
|
|
3
4
|
SessionConflict,
|
|
4
5
|
SessionInvalid,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
type StatefulSessionPersistence,
|
|
11
12
|
type StatefulSessionRecord,
|
|
12
13
|
type SessionRepository,
|
|
14
|
+
type SessionUnavailable,
|
|
13
15
|
} from "@yielded/auth/Sessions";
|
|
14
16
|
import { DateTime, Effect, Option, Schema } from "effect";
|
|
15
17
|
import type { SqlClient } from "effect/sql/SqlClient";
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
registerSqlBatchPostcondition,
|
|
44
46
|
registerSqlPostcondition,
|
|
45
47
|
} from "./sql-commit";
|
|
48
|
+
import { canJoinTextColumns, withStorageValidation } from "./storage-validation";
|
|
46
49
|
import type { AnyTableModel } from "./table-model";
|
|
47
50
|
|
|
48
51
|
export type NativeStatefulSessionMapping<Claims> = StatefulSessionMapping<
|
|
@@ -63,7 +66,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
63
66
|
readonly statefulSessionPersistence: StatefulSessionPersistence<Claims>;
|
|
64
67
|
readonly sessionRepository: SessionRepository;
|
|
65
68
|
},
|
|
66
|
-
|
|
69
|
+
SessionUnavailable,
|
|
67
70
|
SqlClient | LifecycleHooks | SqlBatchCommit
|
|
68
71
|
> {
|
|
69
72
|
const batch = yield* SqlBatchCommit;
|
|
@@ -89,11 +92,12 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
89
92
|
work: Effect.Effect<A, E, R>,
|
|
90
93
|
mode: "transaction" | "statement" = "transaction",
|
|
91
94
|
) =>
|
|
92
|
-
batch === undefined
|
|
95
|
+
(batch === undefined
|
|
93
96
|
? executor.operation(normalizeSessionOperation(work), mode)
|
|
94
97
|
: executor
|
|
95
98
|
.operationBatch(normalizeSessionOperation(work))
|
|
96
|
-
.pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
99
|
+
.pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
100
|
+
).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
|
|
97
101
|
|
|
98
102
|
const stage = Effect.fnUntraced(function* (statement: Fragment, expected?: number) {
|
|
99
103
|
if (batch !== undefined) {
|
|
@@ -118,6 +122,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
118
122
|
|
|
119
123
|
sessionInvariant(rows.length === 1);
|
|
120
124
|
}).pipe(
|
|
125
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
121
126
|
Effect.mapError((cause) => PersistenceMappingError.make({ operation: "decode", cause })),
|
|
122
127
|
),
|
|
123
128
|
});
|
|
@@ -139,47 +144,63 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
139
144
|
),
|
|
140
145
|
);
|
|
141
146
|
|
|
147
|
+
const [joinedIds, joinedRevisions] = yield* Effect.forEach(
|
|
148
|
+
[
|
|
149
|
+
[
|
|
150
|
+
table.unencodedTextColumn?.(s.subjectId),
|
|
151
|
+
state.subject.unencodedTextColumn?.(mapping.subject.id),
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
table.unencodedTextColumn?.(s.securityRevision),
|
|
155
|
+
state.subject.unencodedTextColumn?.(mapping.subject.securityRevision),
|
|
156
|
+
],
|
|
157
|
+
],
|
|
158
|
+
canJoinTextColumns,
|
|
159
|
+
).pipe(withStorageValidation, Effect.mapError(sessionUnavailable));
|
|
160
|
+
|
|
142
161
|
const verify = Effect.fnUntraced(function* (digest: StatefulSessionRecord<Claims>["digest"]) {
|
|
143
162
|
const subject = state.subject.as("verified_subject"),
|
|
144
163
|
session = table.as("verified_session"),
|
|
145
164
|
owner = mapping.subject;
|
|
146
165
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
state.subject.unencodedTextColumn?.(owner.id) !== undefined
|
|
150
|
-
) {
|
|
151
|
-
const rows =
|
|
152
|
-
yield* sql`select ${session.fields("session_")}, ${subject.fields("subject_")} from ${session.name} join ${subject.name} on ${exactSqlText(sql, subject.column(owner.id), session.column(s.subjectId))} and ${state.activeSubject(subject)} and ${exactSqlText(sql, subject.column(owner.securityRevision), session.column(s.securityRevision))} where ${exact(session, s.digest, digest)} and ${live(session)} limit 2`;
|
|
153
|
-
|
|
154
|
-
sessionInvariant(rows.length <= 1);
|
|
155
|
-
if (rows[0] === undefined) return undefined;
|
|
156
|
-
const record = yield* records.decode(session.decode(rows[0], "session_"));
|
|
157
|
-
const decoded = subject.decode(rows[0], "subject_");
|
|
166
|
+
let discoveredSubjectId: StatefulSessionRecord<Claims>["subjectId"] | undefined;
|
|
167
|
+
let nativeSubject: unknown;
|
|
158
168
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
decoded[owner.securityRevision] === record.securityRevision &&
|
|
162
|
-
owner.isActiveStatus(decoded[owner.status]),
|
|
163
|
-
);
|
|
169
|
+
if (!joinedIds) {
|
|
170
|
+
const discovered = yield* records.read(digest);
|
|
164
171
|
|
|
165
|
-
return
|
|
172
|
+
if (discovered === undefined) return undefined;
|
|
173
|
+
discoveredSubjectId = discovered.subjectId;
|
|
174
|
+
nativeSubject = yield* mapping.subjectId.toNative(discoveredSubjectId);
|
|
166
175
|
}
|
|
167
|
-
const record = yield* records.read(digest);
|
|
168
176
|
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
const owns = joinedIds
|
|
178
|
+
? exactSqlText(sql, subject.column(owner.id), session.column(s.subjectId))
|
|
179
|
+
: sql`${state.id(subject, owner.id, nativeSubject)} and ${state.id(session, s.subjectId, nativeSubject)}`;
|
|
180
|
+
|
|
181
|
+
const revision = joinedRevisions
|
|
182
|
+
? exactSqlText(
|
|
183
|
+
sql,
|
|
184
|
+
subject.column(owner.securityRevision),
|
|
185
|
+
session.column(s.securityRevision),
|
|
186
|
+
)
|
|
187
|
+
: sql`true`;
|
|
171
188
|
|
|
172
189
|
const rows =
|
|
173
|
-
yield* sql`select ${subject.fields("subject_")} from ${
|
|
190
|
+
yield* sql`select ${session.fields("session_")}, ${subject.fields("subject_")} from ${session.name} join ${subject.name} on ${owns} and ${state.activeSubject(subject)} and ${revision} where ${exact(session, s.digest, digest)} and ${live(session)} limit 2`;
|
|
174
191
|
|
|
175
192
|
sessionInvariant(rows.length <= 1);
|
|
176
193
|
if (rows[0] === undefined) return undefined;
|
|
194
|
+
const record = yield* records.decode(session.decode(rows[0], "session_"));
|
|
195
|
+
const decoded = subject.decode(rows[0], "subject_");
|
|
196
|
+
|
|
177
197
|
sessionInvariant(
|
|
178
|
-
(
|
|
179
|
-
record.subjectId
|
|
198
|
+
(joinedIds || record.subjectId === discoveredSubjectId) &&
|
|
199
|
+
(yield* mapping.subjectId.toSubject(decoded[owner.id])) === record.subjectId &&
|
|
200
|
+
owner.isActiveStatus(decoded[owner.status]),
|
|
180
201
|
);
|
|
181
202
|
|
|
182
|
-
return record;
|
|
203
|
+
return decoded[owner.securityRevision] === record.securityRevision ? record : undefined;
|
|
183
204
|
});
|
|
184
205
|
|
|
185
206
|
const persistence: StatefulSessionPersistence<Claims> = {
|
|
@@ -201,10 +222,21 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
201
222
|
current.now,
|
|
202
223
|
);
|
|
203
224
|
|
|
204
|
-
const
|
|
225
|
+
const issued = Math.max(
|
|
226
|
+
current.now,
|
|
227
|
+
DateTime.toEpochMillis(input.now),
|
|
228
|
+
DateTime.toEpochMillis(input.session.issuedAt),
|
|
229
|
+
...input.evidence.proofs.map((proof) => DateTime.toEpochMillis(proof.verifiedAt)),
|
|
230
|
+
),
|
|
231
|
+
expires = DateTime.toEpochMillis(input.session.expiresAt),
|
|
205
232
|
absolute = DateTime.toEpochMillis(input.session.absoluteExpiresAt);
|
|
206
233
|
|
|
207
|
-
if (
|
|
234
|
+
if (
|
|
235
|
+
issued - current.now > state.clockPolicy.futureToleranceMillis ||
|
|
236
|
+
issued >= expires ||
|
|
237
|
+
current.now >= expires ||
|
|
238
|
+
expires > absolute
|
|
239
|
+
)
|
|
208
240
|
return yield* StaleAuthentication.make({});
|
|
209
241
|
let pendingExpiry = Infinity;
|
|
210
242
|
|
|
@@ -250,18 +282,16 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
250
282
|
sessionId,
|
|
251
283
|
subjectId: current.revision.subjectId,
|
|
252
284
|
securityRevision: current.revision.securityRevision,
|
|
253
|
-
assurance:
|
|
254
|
-
input.handoffSourceSessionId === undefined
|
|
255
|
-
? assessment.assessed.assurance
|
|
256
|
-
: input.session.assurance,
|
|
257
285
|
provenance: yield* snapshotSessionAuthenticationProvenance({
|
|
258
286
|
evidence: input.evidence,
|
|
259
287
|
}),
|
|
260
|
-
issuedAt: DateTime.makeUnsafe(
|
|
288
|
+
issuedAt: DateTime.makeUnsafe(issued),
|
|
261
289
|
};
|
|
262
290
|
|
|
263
291
|
const deadline = Math.min(expires, assessment.validUntil, pendingExpiry);
|
|
264
|
-
|
|
292
|
+
|
|
293
|
+
yield* state.guardPolicy(current);
|
|
294
|
+
const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
|
|
265
295
|
|
|
266
296
|
const values = {
|
|
267
297
|
...s.encodeInsert(record, { subjectId: current.native, sessionId: nativeSession }),
|
|
@@ -301,6 +331,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
301
331
|
authority.now,
|
|
302
332
|
);
|
|
303
333
|
}).pipe(
|
|
334
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
304
335
|
Effect.mapError((cause) =>
|
|
305
336
|
PersistenceMappingError.make({ operation: "decode", cause }),
|
|
306
337
|
),
|
|
@@ -324,10 +355,15 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
324
355
|
classify(
|
|
325
356
|
Effect.gen(function* () {
|
|
326
357
|
const absolute = DateTime.toEpochMillis(input.record.absoluteExpiresAt),
|
|
327
|
-
expires = DateTime.toEpochMillis(input.nextExpiresAt)
|
|
358
|
+
expires = DateTime.toEpochMillis(input.nextExpiresAt),
|
|
359
|
+
preparedAt = Math.max(
|
|
360
|
+
DateTime.toEpochMillis(input.now),
|
|
361
|
+
DateTime.toEpochMillis(input.record.issuedAt),
|
|
362
|
+
);
|
|
328
363
|
|
|
329
364
|
if (
|
|
330
365
|
expires > absolute ||
|
|
366
|
+
preparedAt >= expires ||
|
|
331
367
|
input.nextDigest === input.record.digest ||
|
|
332
368
|
input.nextCredentialVersion === input.record.credentialVersion
|
|
333
369
|
)
|
|
@@ -348,17 +384,21 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
348
384
|
digest: input.nextDigest,
|
|
349
385
|
credentialVersion: input.nextCredentialVersion,
|
|
350
386
|
expiresAt: input.nextExpiresAt,
|
|
351
|
-
issuedAt: DateTime.makeUnsafe(sampled),
|
|
387
|
+
issuedAt: DateTime.makeUnsafe(Math.max(sampled, preparedAt)),
|
|
352
388
|
};
|
|
353
389
|
|
|
354
390
|
const values = {
|
|
355
391
|
...s.encodeRotation(prepared),
|
|
356
392
|
[s.digest]: prepared.digest,
|
|
357
|
-
[s.issuedAt]: tables.expression(
|
|
393
|
+
[s.issuedAt]: tables.expression(
|
|
394
|
+
mapping.clock.fromMillis(
|
|
395
|
+
sql`case when ${now} >= ${preparedAt} then ${now} else ${preparedAt} end`,
|
|
396
|
+
),
|
|
397
|
+
),
|
|
358
398
|
[s.expiresAt]: s.encodeInstant(prepared.expiresAt),
|
|
359
399
|
};
|
|
360
400
|
|
|
361
|
-
const condition = sql`${yield* records.owner(input.record)} and ${live(table)} and ${now} < ${expires} and ${records.millis(table, s.absoluteExpiresAt)} = ${absolute}`;
|
|
401
|
+
const condition = sql`${yield* records.owner(input.record)} and ${live(table)} and ${preparedAt} <= ${now} + ${state.clockPolicy.futureToleranceMillis} and ${now} < ${expires} and ${records.millis(table, s.absoluteExpiresAt)} = ${absolute}`;
|
|
362
402
|
const update = sql`${table.update(values)} where ${condition}`;
|
|
363
403
|
let record = prepared;
|
|
364
404
|
|
|
@@ -484,26 +524,81 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
484
524
|
yield* Schema.decodeEffect(SessionId)(input.cursor),
|
|
485
525
|
);
|
|
486
526
|
|
|
487
|
-
|
|
488
|
-
|
|
527
|
+
let revision: StatefulSessionRecord<Claims>["securityRevision"] | undefined;
|
|
528
|
+
|
|
529
|
+
// Bind both revisions in SQL so stale rows cannot consume a page.
|
|
530
|
+
if (!joinedRevisions) {
|
|
531
|
+
const subjects =
|
|
532
|
+
yield* sql`select ${st.fields("subject_")} from ${st.name} where ${state.id(st, owner.id, nativeSubject)} and ${state.activeSubject(st)} limit 2`;
|
|
489
533
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
534
|
+
sessionInvariant(subjects.length <= 1);
|
|
535
|
+
if (subjects[0] === undefined) return { sessions: [] };
|
|
536
|
+
const subject = st.decode(subjects[0], "subject_");
|
|
537
|
+
|
|
538
|
+
sessionInvariant(
|
|
539
|
+
(yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId,
|
|
540
|
+
);
|
|
541
|
+
revision = yield* Schema.decodeUnknownEffect(SessionMetadata.fields.securityRevision)(
|
|
542
|
+
subject[owner.securityRevision],
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Keep the owner in the page snapshot even when its discovered revision changed.
|
|
547
|
+
// Retry that read once; continued authority changes are not pagination exhaustion.
|
|
548
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
549
|
+
const currentRevision = joinedRevisions
|
|
550
|
+
? exactSqlText(
|
|
551
|
+
sql,
|
|
552
|
+
st.column(owner.securityRevision),
|
|
553
|
+
list.column(s.securityRevision),
|
|
554
|
+
)
|
|
555
|
+
: sql`${state.exact(st, owner.securityRevision, revision)} and ${exact(list, s.securityRevision, revision)}`;
|
|
556
|
+
|
|
557
|
+
const rows =
|
|
558
|
+
yield* sql`select ${list.fields("session_")}, ${st.fields("subject_")}, ${list.column(s.subjectId)} as session_owner from ${st.name} left join ${list.name} on ${state.id(list, s.subjectId, nativeSubject)} and ${currentRevision} and ${live(list)}${cursor === undefined ? sql`` : sql` and ${list.column(s.sessionId)} > ${list.value(s.sessionId, cursor)}`} where ${state.id(st, owner.id, nativeSubject)} and ${state.activeSubject(st)} order by ${list.column(s.sessionId)} limit ${input.limit + 1}`;
|
|
559
|
+
|
|
560
|
+
const first = rows[0];
|
|
561
|
+
|
|
562
|
+
if (first === undefined) return { sessions: [] };
|
|
563
|
+
if (!joinedRevisions) {
|
|
564
|
+
const subject = st.decode(first, "subject_");
|
|
493
565
|
|
|
494
566
|
sessionInvariant(
|
|
495
|
-
|
|
496
|
-
(yield* mapping.subjectId.toSubject(st.decode(row, "subject_")[owner.id])) ===
|
|
497
|
-
input.subjectId,
|
|
567
|
+
(yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId,
|
|
498
568
|
);
|
|
499
569
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
570
|
+
const current = yield* Schema.decodeUnknownEffect(
|
|
571
|
+
SessionMetadata.fields.securityRevision,
|
|
572
|
+
)(subject[owner.securityRevision]);
|
|
573
|
+
|
|
574
|
+
if (current !== revision) {
|
|
575
|
+
revision = current;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (first.session_owner === null) return { sessions: [] };
|
|
580
|
+
|
|
581
|
+
const decoded = yield* Effect.forEach(rows.slice(0, input.limit), (row) =>
|
|
582
|
+
Effect.gen(function* () {
|
|
583
|
+
const record = yield* records.decode(list.decode(row, "session_"));
|
|
584
|
+
const subject = st.decode(row, "subject_");
|
|
585
|
+
|
|
586
|
+
sessionInvariant(
|
|
587
|
+
record.subjectId === input.subjectId &&
|
|
588
|
+
(yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId &&
|
|
589
|
+
subject[owner.securityRevision] === record.securityRevision,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
return yield* Schema.decodeEffect(Schema.toType(SessionMetadata))(record);
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const nextCursor = rows.length > input.limit ? decoded.at(-1)?.sessionId : undefined;
|
|
503
597
|
|
|
504
|
-
|
|
598
|
+
return { sessions: decoded, ...(nextCursor === undefined ? {} : { nextCursor }) };
|
|
599
|
+
}
|
|
505
600
|
|
|
506
|
-
return
|
|
601
|
+
return yield* sessionUnavailable();
|
|
507
602
|
}),
|
|
508
603
|
),
|
|
509
604
|
};
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type { LifecycleHooks } from "@yielded/auth/Hooks";
|
|
2
|
-
import {
|
|
2
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
3
3
|
import {
|
|
4
4
|
SessionConflict,
|
|
5
5
|
SessionStepUpInvalid,
|
|
6
6
|
StaleAuthentication,
|
|
7
|
-
SessionMetadata,
|
|
8
7
|
type SessionStepUpPersistence,
|
|
9
8
|
type SessionStepUpIntent,
|
|
10
9
|
type StatefulSessionRecord,
|
|
10
|
+
type SessionUnavailable,
|
|
11
11
|
} from "@yielded/auth/Sessions";
|
|
12
|
-
import { DateTime, Effect, Option
|
|
12
|
+
import { DateTime, Effect, Option } from "effect";
|
|
13
13
|
import type { SqlClient } from "effect/sql/SqlClient";
|
|
14
14
|
import type { Fragment } from "effect/sql/Statement";
|
|
15
15
|
|
|
@@ -58,14 +58,12 @@ export type NativeSessionStepUpMapping<Claims> = SessionStepUpMapping<
|
|
|
58
58
|
unknown
|
|
59
59
|
>;
|
|
60
60
|
|
|
61
|
-
const assuranceJson = Schema.encodeSync(Schema.fromJsonString(SessionMetadata.fields.assurance));
|
|
62
|
-
|
|
63
61
|
export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Claims>(
|
|
64
62
|
tables: NativeSqlTables,
|
|
65
63
|
mapping: NativeSessionStepUpMapping<Claims>,
|
|
66
64
|
): Effect.fn.Return<
|
|
67
65
|
{ readonly sessionStepUpPersistence: SessionStepUpPersistence<Claims> },
|
|
68
|
-
|
|
66
|
+
SessionUnavailable,
|
|
69
67
|
SqlClient | LifecycleHooks | SqlBatchCommit
|
|
70
68
|
> {
|
|
71
69
|
const batch = yield* SqlBatchCommit;
|
|
@@ -92,11 +90,12 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
92
90
|
work: Effect.Effect<A, E, R>,
|
|
93
91
|
mode: "transaction" | "statement" = "transaction",
|
|
94
92
|
) =>
|
|
95
|
-
batch === undefined
|
|
93
|
+
(batch === undefined
|
|
96
94
|
? executor.operation(normalizeSessionOperation(work), mode)
|
|
97
95
|
: executor
|
|
98
96
|
.operationBatch(normalizeSessionOperation(work))
|
|
99
|
-
.pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
97
|
+
.pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
98
|
+
).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
|
|
100
99
|
|
|
101
100
|
const decode = Effect.fnUntraced(function* (stored: StoredSessionPending) {
|
|
102
101
|
const intent = yield* decodeStepUpIntent(stored.snapshot);
|
|
@@ -118,7 +117,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
118
117
|
intent: Omit<SessionStepUpIntent, "version">,
|
|
119
118
|
instant: number,
|
|
120
119
|
) {
|
|
121
|
-
if (!stepUpIntentLive(intent, mapping.source.kind, DateTime.makeUnsafe(instant)))
|
|
120
|
+
if (!(yield* stepUpIntentLive(intent, mapping.source.kind, DateTime.makeUnsafe(instant))))
|
|
122
121
|
return undefined;
|
|
123
122
|
const subject = yield* mapping.subjectId.toNative(intent.revision.subjectId);
|
|
124
123
|
|
|
@@ -178,6 +177,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
178
177
|
check: Effect.gen(function* () {
|
|
179
178
|
sessionInvariant((yield* sql`select 1 where ${condition}`).length === 1);
|
|
180
179
|
}).pipe(
|
|
180
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
181
181
|
Effect.mapError((cause) => PersistenceMappingError.make({ operation: "decode", cause })),
|
|
182
182
|
),
|
|
183
183
|
});
|
|
@@ -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} >= ${current.now} and ${DateTime.toEpochMillis(intent.sourceAuthenticatedAt)} <= ${now} + ${state.clockPolicy.futureToleranceMillis} and ${now} < ${DateTime.toEpochMillis(intent.expiresAt)}`;
|
|
215
216
|
|
|
216
217
|
yield* pending
|
|
217
218
|
.insert(
|
|
@@ -257,7 +258,11 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
257
258
|
|
|
258
259
|
if (
|
|
259
260
|
!sameSessionRevision(current.revision, intent.revision) ||
|
|
260
|
-
!
|
|
261
|
+
!(yield* stepUpIntentLive(
|
|
262
|
+
intent,
|
|
263
|
+
mapping.source.kind,
|
|
264
|
+
DateTime.makeUnsafe(current.now),
|
|
265
|
+
))
|
|
261
266
|
)
|
|
262
267
|
return undefined;
|
|
263
268
|
|
|
@@ -267,6 +272,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
267
272
|
}),
|
|
268
273
|
)
|
|
269
274
|
.pipe(
|
|
275
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
270
276
|
Effect.flatMap((value) =>
|
|
271
277
|
value === undefined
|
|
272
278
|
? Effect.fail(SessionStepUpInvalid.make({}))
|
|
@@ -312,20 +318,16 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
312
318
|
return yield* SessionStepUpInvalid.make({});
|
|
313
319
|
}
|
|
314
320
|
|
|
315
|
-
const
|
|
321
|
+
const prepared = yield* sessionEvidenceDeadline(
|
|
322
|
+
evidence,
|
|
323
|
+
plan.baseRequirement,
|
|
324
|
+
current.now,
|
|
325
|
+
),
|
|
326
|
+
base = yield* sessionEvidenceDeadline(evidence, current.requirement, current.now),
|
|
316
327
|
profile = yield* sessionEvidenceDeadline(evidence, intent.requirement, current.now);
|
|
317
328
|
|
|
318
|
-
if (
|
|
319
|
-
assuranceJson(
|
|
320
|
-
AuthenticationAssurance.make({
|
|
321
|
-
...base.assessed.assurance,
|
|
322
|
-
authenticatedAt: profile.assessed.assurance.authenticatedAt,
|
|
323
|
-
}),
|
|
324
|
-
) !== assuranceJson(replacement.inspection.session.assurance)
|
|
325
|
-
)
|
|
326
|
-
return yield* SessionStepUpInvalid.make({});
|
|
327
|
-
|
|
328
329
|
const deadline = Math.min(
|
|
330
|
+
prepared.validUntil,
|
|
329
331
|
base.validUntil,
|
|
330
332
|
profile.validUntil,
|
|
331
333
|
DateTime.toEpochMillis(intent.expiresAt),
|
|
@@ -334,13 +336,16 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
334
336
|
|
|
335
337
|
if (
|
|
336
338
|
current.now >= deadline ||
|
|
337
|
-
DateTime.toEpochMillis(replacement.inspection.session.issuedAt)
|
|
339
|
+
DateTime.toEpochMillis(replacement.inspection.session.issuedAt) - current.now >
|
|
340
|
+
state.clockPolicy.futureToleranceMillis ||
|
|
338
341
|
DateTime.toEpochMillis(replacement.inspection.session.expiresAt) >
|
|
339
342
|
DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt)
|
|
340
343
|
)
|
|
341
344
|
return yield* SessionStepUpInvalid.make({});
|
|
342
345
|
const snapshot = yield* encodeStepUpIntent(intent);
|
|
343
|
-
|
|
346
|
+
|
|
347
|
+
yield* state.guardPolicy(current);
|
|
348
|
+
const authority = sql`${state.authorityCondition(evidence.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
|
|
344
349
|
|
|
345
350
|
yield* pending.consume(
|
|
346
351
|
"StepUp",
|
|
@@ -427,6 +432,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
427
432
|
sessionInvariant(
|
|
428
433
|
actual !== undefined && sameSessionRevision(actual.revision, evidence.revision),
|
|
429
434
|
);
|
|
435
|
+
yield* sessionEvidenceDeadline(evidence, plan.baseRequirement, actual.now);
|
|
430
436
|
yield* sessionEvidenceDeadline(evidence, actual.requirement, actual.now);
|
|
431
437
|
yield* sessionEvidenceDeadline(evidence, intent.requirement, actual.now);
|
|
432
438
|
if (replacementRecord !== undefined && records !== undefined) {
|
|
@@ -435,6 +441,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
435
441
|
sessionInvariant(row !== undefined && sameSessionRecord(row, replacementRecord));
|
|
436
442
|
}
|
|
437
443
|
}).pipe(
|
|
444
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
438
445
|
Effect.mapError((cause) =>
|
|
439
446
|
PersistenceMappingError.make({ operation: "decode", cause }),
|
|
440
447
|
),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LifecycleHooks } from "@yielded/auth/Hooks";
|
|
2
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
2
3
|
import {
|
|
3
4
|
SessionInvalid,
|
|
4
5
|
StaleAuthentication,
|
|
@@ -46,6 +47,7 @@ export const makeNativeSignedSessionValidityServices = Effect.fnUntraced(functio
|
|
|
46
47
|
SqlClient | LifecycleHooks | SqlBatchCommit
|
|
47
48
|
> {
|
|
48
49
|
const batch = yield* SqlBatchCommit;
|
|
50
|
+
const { futureToleranceMillis } = yield* AuthenticationClock;
|
|
49
51
|
|
|
50
52
|
const sql = (yield* SqlClient).withoutTransforms(),
|
|
51
53
|
executor = yield* makeSqlCommitExecutor(sessionUnavailable);
|
|
@@ -113,7 +115,7 @@ export const makeNativeSignedSessionValidityServices = Effect.fnUntraced(functio
|
|
|
113
115
|
nativeSession = yield* mapping.sessionId.toNative(record.sessionId);
|
|
114
116
|
|
|
115
117
|
const rows =
|
|
116
|
-
yield* sql`select ${subject.fields("subject_")} from ${subject.name} where ${id(subject, s.id, nativeSubject)} and ${id(subject, s.status, s.activeStatusValue)} and ${exact(subject, s.securityRevision, record.securityRevision)} and ${now}
|
|
118
|
+
yield* sql`select ${subject.fields("subject_")} from ${subject.name} where ${id(subject, s.id, nativeSubject)} and ${id(subject, s.status, s.activeStatusValue)} and ${exact(subject, s.securityRevision, record.securityRevision)} and ${DateTime.toEpochMillis(record.issuedAt)} <= ${now} + ${futureToleranceMillis} and ${DateTime.toEpochMillis(record.issuedAt)} < ${DateTime.toEpochMillis(record.expiresAt)} and ${now} < ${DateTime.toEpochMillis(record.expiresAt)} and ${DateTime.toEpochMillis(record.expiresAt)} <= ${DateTime.toEpochMillis(record.absoluteExpiresAt)} and not exists(select 1 from ${tombstone.name} where ${key(nativeSubject, nativeSession)}) limit 2`;
|
|
117
119
|
|
|
118
120
|
sessionInvariant(rows.length <= 1);
|
|
119
121
|
if (rows[0] === undefined) return false;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LifecycleHooks } from "@yielded/auth/Hooks";
|
|
2
|
+
import { reportAuthDiagnostic } from "@yielded/auth/Persistence";
|
|
2
3
|
import { SessionUnavailable, type AuthenticationAuthority } from "@yielded/auth/Sessions";
|
|
3
4
|
import { Effect } from "effect";
|
|
4
5
|
import { SqlClient } from "effect/sql";
|
|
@@ -32,10 +33,13 @@ export const makeAuthenticationAuthorityServices = Effect.fnUntraced(function* <
|
|
|
32
33
|
> {
|
|
33
34
|
const sql = yield* SqlClient.SqlClient;
|
|
34
35
|
|
|
35
|
-
if (!sql.onDialectOrElse({ pg: () => true, sqlite: () => true, orElse: () => false }))
|
|
36
|
+
if (!sql.onDialectOrElse({ pg: () => true, sqlite: () => true, orElse: () => false })) {
|
|
37
|
+
yield* reportAuthDiagnostic("persistence-validation", "configuration");
|
|
38
|
+
|
|
36
39
|
return yield* PersistenceConfigurationError.make({
|
|
37
40
|
reason: "Authentication authority requires PostgreSQL or SQLite",
|
|
38
41
|
});
|
|
42
|
+
}
|
|
39
43
|
yield* validateSqlStorage(mapping).pipe(Effect.mapError(() => SessionUnavailable.make({})));
|
|
40
44
|
|
|
41
45
|
// Erase mapped native ID callback variance, never decoded authentication data.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reportAuthDiagnostic } from "@yielded/auth/Persistence";
|
|
1
2
|
import { Effect } from "effect";
|
|
2
3
|
import { SqlClient } from "effect/sql";
|
|
3
4
|
|
|
@@ -7,57 +8,63 @@ import { storageKeyPlans } from "./storage-plans";
|
|
|
7
8
|
import { validateStorageBatch, type StorageValidation } from "./storage-validation";
|
|
8
9
|
|
|
9
10
|
/** Check declared mappings against physical keys before acquiring OAuth services. */
|
|
10
|
-
export const validateSqlStorage = Effect.fnUntraced(
|
|
11
|
-
|
|
12
|
-
try
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
export const validateSqlStorage = Effect.fnUntraced(
|
|
12
|
+
function* (mapping: unknown) {
|
|
13
|
+
const plans = yield* Effect.try({
|
|
14
|
+
try: () =>
|
|
15
|
+
storageKeyPlans(mapping, (table) => {
|
|
16
|
+
if (!(table instanceof Table)) throw new Error("Expected a SQL table");
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
return table.columns;
|
|
19
|
+
}),
|
|
20
|
+
catch: () =>
|
|
21
|
+
PersistenceMappingError.make({
|
|
22
|
+
operation: "mapping",
|
|
23
|
+
cause: "Invalid storage key mapping",
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
pg: () => "pg" as const,
|
|
26
|
-
sqlite: () => "sqlite" as const,
|
|
27
|
-
orElse: () => undefined,
|
|
28
|
-
});
|
|
27
|
+
const client = yield* SqlClient.SqlClient;
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
const dialect = client.onDialectOrElse({
|
|
30
|
+
pg: () => "pg" as const,
|
|
31
|
+
sqlite: () => "sqlite" as const,
|
|
32
|
+
orElse: () => undefined,
|
|
34
33
|
});
|
|
35
|
-
const validations: StorageValidation[] = [];
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
if (!(plan.table instanceof Table))
|
|
35
|
+
if (dialect === undefined)
|
|
39
36
|
return yield* PersistenceMappingError.make({
|
|
40
37
|
operation: "mapping",
|
|
41
|
-
cause: "
|
|
38
|
+
cause: "OAuth persistence requires PostgreSQL or SQLite",
|
|
42
39
|
});
|
|
43
|
-
validations
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
40
|
+
const validations: StorageValidation[] = [];
|
|
41
|
+
|
|
42
|
+
for (const plan of plans) {
|
|
43
|
+
if (!(plan.table instanceof Table))
|
|
44
|
+
return yield* PersistenceMappingError.make({
|
|
45
|
+
operation: "mapping",
|
|
46
|
+
cause: "Expected a SQL table",
|
|
47
|
+
});
|
|
48
|
+
validations.push({
|
|
49
|
+
table: {
|
|
50
|
+
name: plan.table.name,
|
|
51
|
+
...(plan.table.schema === undefined ? {} : { schema: plan.table.schema }),
|
|
52
|
+
columns: Object.fromEntries(
|
|
53
|
+
Object.entries(plan.table.columns).map(([key, column]) => [key, column.options]),
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
required: [plan.keys],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
yield* validateStorageBatch(dialect, validations).pipe(
|
|
60
|
+
Effect.provideService(SqlClient.SqlClient, client),
|
|
61
|
+
Effect.mapError(() =>
|
|
62
|
+
PersistenceMappingError.make({
|
|
63
|
+
operation: "mapping",
|
|
64
|
+
cause: "Required SQL storage key is unavailable",
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
Effect.tapError(() => reportAuthDiagnostic("persistence-validation", "mapping")),
|
|
70
|
+
);
|
|
@@ -22,6 +22,7 @@ export const identifier = (name: string) => '"' + name.replaceAll('"', '""') + '
|
|
|
22
22
|
|
|
23
23
|
export interface ColumnOptions {
|
|
24
24
|
readonly name: string;
|
|
25
|
+
/** Driver value representation, not the database column's physical SQL type. */
|
|
25
26
|
readonly type: "text" | "integer" | "boolean";
|
|
26
27
|
readonly nullable?: boolean;
|
|
27
28
|
}
|