@yielded/auth-persistence 0.1.0-beta.31 → 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 +9 -2
- 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 +31 -5
- 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/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 +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 +9 -6
- 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/persistence.mjs +96 -37
- 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/session-native-authority.mjs +6 -5
- 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 +2 -1
- 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 +3 -0
- 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 +65 -37
- 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 +14 -18
- 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/step-up-state.mjs +19 -6
- package/dist/internal/step-up-state.mjs.map +1 -1
- package/dist/internal/storage-mapping.mjs +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 +46 -7
- 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/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-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 +17 -9
- package/src/internal/password-policy.ts +16 -8
- package/src/internal/persistence.ts +216 -70
- package/src/internal/phone-native.ts +1 -1
- package/src/internal/session-native-authority.ts +23 -7
- package/src/internal/session-native-login.ts +6 -3
- 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 +3 -0
- package/src/internal/session-native-stateful.ts +144 -51
- package/src/internal/session-native-step-up.ts +27 -23
- 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/step-up-state.ts +43 -6
- package/src/internal/storage-tables.ts +76 -0
- package/src/internal/storage-validation.ts +127 -27
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
1
2
|
import { TokenDigest } from "@yielded/auth/Schema";
|
|
2
3
|
import {
|
|
3
4
|
SessionId,
|
|
@@ -50,14 +51,14 @@ export const makeConditionalSqlInsert = Effect.fnUntraced(function* () {
|
|
|
50
51
|
};
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
/** Security columns
|
|
54
|
-
*
|
|
55
|
-
* copy of prepared metadata. Claims/provenance decoding remains consumer-owned. */
|
|
54
|
+
/** Security columns own live time bounds and commit-time issuedAt adjustments,
|
|
55
|
+
* independently of prepared JSON metadata. Claims/provenance decoding remains consumer-owned. */
|
|
56
56
|
export const makeNativeSessionRecords = Effect.fnUntraced(function* <Claims>(
|
|
57
57
|
tables: NativeSqlTables,
|
|
58
58
|
mapping: NativeSessionRecordMapping<Claims>,
|
|
59
59
|
) {
|
|
60
60
|
const sql = (yield* SqlClient).withoutTransforms();
|
|
61
|
+
const { futureToleranceMillis } = yield* AuthenticationClock;
|
|
61
62
|
|
|
62
63
|
const s = mapping.session,
|
|
63
64
|
table = tables(s.table),
|
|
@@ -71,7 +72,8 @@ export const makeNativeSessionRecords = Effect.fnUntraced(function* <Claims>(
|
|
|
71
72
|
|
|
72
73
|
const live = (t: SqlTable) =>
|
|
73
74
|
sql.and([
|
|
74
|
-
sql`${millis(t, s.issuedAt)} <= ${now}`,
|
|
75
|
+
sql`${millis(t, s.issuedAt)} <= ${now} + ${futureToleranceMillis}`,
|
|
76
|
+
sql`${millis(t, s.issuedAt)} < ${millis(t, s.expiresAt)}`,
|
|
75
77
|
sql`${millis(t, s.expiresAt)} > ${now}`,
|
|
76
78
|
sql`${millis(t, s.absoluteExpiresAt)} >= ${millis(t, s.expiresAt)}`,
|
|
77
79
|
]);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
1
2
|
import { reportAuthFailure } from "@yielded/auth/Persistence";
|
|
2
3
|
import type { SubjectId } from "@yielded/auth/Schema";
|
|
3
4
|
import {
|
|
@@ -98,6 +99,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
|
|
|
98
99
|
batch = false,
|
|
99
100
|
) {
|
|
100
101
|
const sql = (yield* SqlClient).withoutTransforms();
|
|
102
|
+
const clockPolicy = yield* AuthenticationClock;
|
|
101
103
|
|
|
102
104
|
const s = mapping.subject,
|
|
103
105
|
c = mapping.credential;
|
|
@@ -246,6 +248,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
|
|
|
246
248
|
|
|
247
249
|
return {
|
|
248
250
|
sql,
|
|
251
|
+
clockPolicy,
|
|
249
252
|
subject,
|
|
250
253
|
credential,
|
|
251
254
|
joinedSubject,
|
|
@@ -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,14 +282,10 @@ 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);
|
|
@@ -303,6 +331,7 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
303
331
|
authority.now,
|
|
304
332
|
);
|
|
305
333
|
}).pipe(
|
|
334
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
306
335
|
Effect.mapError((cause) =>
|
|
307
336
|
PersistenceMappingError.make({ operation: "decode", cause }),
|
|
308
337
|
),
|
|
@@ -326,10 +355,15 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
326
355
|
classify(
|
|
327
356
|
Effect.gen(function* () {
|
|
328
357
|
const absolute = DateTime.toEpochMillis(input.record.absoluteExpiresAt),
|
|
329
|
-
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
|
+
);
|
|
330
363
|
|
|
331
364
|
if (
|
|
332
365
|
expires > absolute ||
|
|
366
|
+
preparedAt >= expires ||
|
|
333
367
|
input.nextDigest === input.record.digest ||
|
|
334
368
|
input.nextCredentialVersion === input.record.credentialVersion
|
|
335
369
|
)
|
|
@@ -350,17 +384,21 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
350
384
|
digest: input.nextDigest,
|
|
351
385
|
credentialVersion: input.nextCredentialVersion,
|
|
352
386
|
expiresAt: input.nextExpiresAt,
|
|
353
|
-
issuedAt: DateTime.makeUnsafe(sampled),
|
|
387
|
+
issuedAt: DateTime.makeUnsafe(Math.max(sampled, preparedAt)),
|
|
354
388
|
};
|
|
355
389
|
|
|
356
390
|
const values = {
|
|
357
391
|
...s.encodeRotation(prepared),
|
|
358
392
|
[s.digest]: prepared.digest,
|
|
359
|
-
[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
|
+
),
|
|
360
398
|
[s.expiresAt]: s.encodeInstant(prepared.expiresAt),
|
|
361
399
|
};
|
|
362
400
|
|
|
363
|
-
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}`;
|
|
364
402
|
const update = sql`${table.update(values)} where ${condition}`;
|
|
365
403
|
let record = prepared;
|
|
366
404
|
|
|
@@ -486,26 +524,81 @@ export const makeNativeStatefulSessionServices = Effect.fnUntraced(function* <Cl
|
|
|
486
524
|
yield* Schema.decodeEffect(SessionId)(input.cursor),
|
|
487
525
|
);
|
|
488
526
|
|
|
489
|
-
|
|
490
|
-
|
|
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`;
|
|
533
|
+
|
|
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];
|
|
491
561
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
562
|
+
if (first === undefined) return { sessions: [] };
|
|
563
|
+
if (!joinedRevisions) {
|
|
564
|
+
const subject = st.decode(first, "subject_");
|
|
495
565
|
|
|
496
566
|
sessionInvariant(
|
|
497
|
-
|
|
498
|
-
(yield* mapping.subjectId.toSubject(st.decode(row, "subject_")[owner.id])) ===
|
|
499
|
-
input.subjectId,
|
|
567
|
+
(yield* mapping.subjectId.toSubject(subject[owner.id])) === input.subjectId,
|
|
500
568
|
);
|
|
501
569
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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;
|
|
505
597
|
|
|
506
|
-
|
|
598
|
+
return { sessions: decoded, ...(nextCursor === undefined ? {} : { nextCursor }) };
|
|
599
|
+
}
|
|
507
600
|
|
|
508
|
-
return
|
|
601
|
+
return yield* sessionUnavailable();
|
|
509
602
|
}),
|
|
510
603
|
),
|
|
511
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
|
});
|
|
@@ -212,7 +212,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
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
|
+
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)}`;
|
|
216
216
|
|
|
217
217
|
yield* pending
|
|
218
218
|
.insert(
|
|
@@ -258,7 +258,11 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
258
258
|
|
|
259
259
|
if (
|
|
260
260
|
!sameSessionRevision(current.revision, intent.revision) ||
|
|
261
|
-
!
|
|
261
|
+
!(yield* stepUpIntentLive(
|
|
262
|
+
intent,
|
|
263
|
+
mapping.source.kind,
|
|
264
|
+
DateTime.makeUnsafe(current.now),
|
|
265
|
+
))
|
|
262
266
|
)
|
|
263
267
|
return undefined;
|
|
264
268
|
|
|
@@ -268,6 +272,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
268
272
|
}),
|
|
269
273
|
)
|
|
270
274
|
.pipe(
|
|
275
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
271
276
|
Effect.flatMap((value) =>
|
|
272
277
|
value === undefined
|
|
273
278
|
? Effect.fail(SessionStepUpInvalid.make({}))
|
|
@@ -313,20 +318,16 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
313
318
|
return yield* SessionStepUpInvalid.make({});
|
|
314
319
|
}
|
|
315
320
|
|
|
316
|
-
const
|
|
321
|
+
const prepared = yield* sessionEvidenceDeadline(
|
|
322
|
+
evidence,
|
|
323
|
+
plan.baseRequirement,
|
|
324
|
+
current.now,
|
|
325
|
+
),
|
|
326
|
+
base = yield* sessionEvidenceDeadline(evidence, current.requirement, current.now),
|
|
317
327
|
profile = yield* sessionEvidenceDeadline(evidence, intent.requirement, current.now);
|
|
318
328
|
|
|
319
|
-
if (
|
|
320
|
-
assuranceJson(
|
|
321
|
-
AuthenticationAssurance.make({
|
|
322
|
-
...base.assessed.assurance,
|
|
323
|
-
authenticatedAt: profile.assessed.assurance.authenticatedAt,
|
|
324
|
-
}),
|
|
325
|
-
) !== assuranceJson(replacement.inspection.session.assurance)
|
|
326
|
-
)
|
|
327
|
-
return yield* SessionStepUpInvalid.make({});
|
|
328
|
-
|
|
329
329
|
const deadline = Math.min(
|
|
330
|
+
prepared.validUntil,
|
|
330
331
|
base.validUntil,
|
|
331
332
|
profile.validUntil,
|
|
332
333
|
DateTime.toEpochMillis(intent.expiresAt),
|
|
@@ -335,7 +336,8 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
335
336
|
|
|
336
337
|
if (
|
|
337
338
|
current.now >= deadline ||
|
|
338
|
-
DateTime.toEpochMillis(replacement.inspection.session.issuedAt)
|
|
339
|
+
DateTime.toEpochMillis(replacement.inspection.session.issuedAt) - current.now >
|
|
340
|
+
state.clockPolicy.futureToleranceMillis ||
|
|
339
341
|
DateTime.toEpochMillis(replacement.inspection.session.expiresAt) >
|
|
340
342
|
DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt)
|
|
341
343
|
)
|
|
@@ -430,6 +432,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
430
432
|
sessionInvariant(
|
|
431
433
|
actual !== undefined && sameSessionRevision(actual.revision, evidence.revision),
|
|
432
434
|
);
|
|
435
|
+
yield* sessionEvidenceDeadline(evidence, plan.baseRequirement, actual.now);
|
|
433
436
|
yield* sessionEvidenceDeadline(evidence, actual.requirement, actual.now);
|
|
434
437
|
yield* sessionEvidenceDeadline(evidence, intent.requirement, actual.now);
|
|
435
438
|
if (replacementRecord !== undefined && records !== undefined) {
|
|
@@ -438,6 +441,7 @@ export const makeNativeSessionStepUpServices = Effect.fnUntraced(function* <Clai
|
|
|
438
441
|
sessionInvariant(row !== undefined && sameSessionRecord(row, replacementRecord));
|
|
439
442
|
}
|
|
440
443
|
}).pipe(
|
|
444
|
+
Effect.provideService(AuthenticationClock, state.clockPolicy),
|
|
441
445
|
Effect.mapError((cause) =>
|
|
442
446
|
PersistenceMappingError.make({ operation: "decode", cause }),
|
|
443
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.
|