@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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { LifecycleHooks } from "@yielded/auth/Hooks";
|
|
2
|
+
import * as M from "@yielded/auth/OAuth";
|
|
3
|
+
import { AuthenticationFlowId } from "@yielded/auth/Sessions";
|
|
4
|
+
import { Context, type Crypto, DateTime, Effect, Schema } from "effect";
|
|
5
|
+
import { SqlClient } from "effect/sql";
|
|
6
|
+
import type { Fragment } from "effect/sql/Statement";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
PersistenceConfigurationError,
|
|
10
|
+
type MappingInput,
|
|
11
|
+
type OAuthFeature,
|
|
12
|
+
} from "./configuration";
|
|
13
|
+
import {
|
|
14
|
+
requiredOAuthConnectedConstraints,
|
|
15
|
+
requiredOAuthConnectedRevocationConstraints,
|
|
16
|
+
type OAuthConnectedPolicyInput,
|
|
17
|
+
} from "./models/oauth-connected-model";
|
|
18
|
+
import { requiredOAuthSignInConstraints } from "./models/oauth-model";
|
|
19
|
+
import type { NativeSqlTables } from "./native-sql-table";
|
|
20
|
+
import { makeNativeOAuthConnectedServices } from "./oauth/native-connected";
|
|
21
|
+
import type { OAuthNativeConnectedMapping } from "./oauth/native-connected-state";
|
|
22
|
+
import { makeNativeOAuthRevocationServices } from "./oauth/native-revocations";
|
|
23
|
+
import { makeNativeOAuthSignInServices } from "./oauth/native-sign-in";
|
|
24
|
+
import type { OAuthNativeReadMapping } from "./oauth/native-state";
|
|
25
|
+
import { sameRevision, satisfies } from "./oauth/state";
|
|
26
|
+
import { exactSqlText } from "./sql-change";
|
|
27
|
+
import { SqlBatchCommit } from "./sql-commit";
|
|
28
|
+
import { makeStorageClock } from "./storage-clock";
|
|
29
|
+
import { storageTables, type StorageRole } from "./storage-tables";
|
|
30
|
+
|
|
31
|
+
// Removed provider grants retain revocation custody for 30 days; claims may extend it.
|
|
32
|
+
const revocationRetentionMillis = 30 * 24 * 60 * 60 * 1000;
|
|
33
|
+
const encodeProfile = Schema.encodeSync(Schema.fromJsonString(M.OAuthConnectedProfile));
|
|
34
|
+
const encodePolicy = Schema.encodeSync(Schema.fromJsonString(M.OAuthConnectedPolicy));
|
|
35
|
+
|
|
36
|
+
const configurationError = (reason: string) => PersistenceConfigurationError.make({ reason });
|
|
37
|
+
|
|
38
|
+
/** Managed tables use the same native OAuth operations as explicit mappings. */
|
|
39
|
+
export const makeManagedOAuth = Effect.fnUntraced(function* (
|
|
40
|
+
storage: MappingInput,
|
|
41
|
+
tables: NativeSqlTables,
|
|
42
|
+
features: ReadonlyArray<OAuthFeature>,
|
|
43
|
+
): Effect.fn.Return<
|
|
44
|
+
Context.Context<never>,
|
|
45
|
+
PersistenceConfigurationError,
|
|
46
|
+
Crypto.Crypto | LifecycleHooks | SqlClient.SqlClient | SqlBatchCommit
|
|
47
|
+
> {
|
|
48
|
+
const sql = (yield* SqlClient.SqlClient).withoutTransforms();
|
|
49
|
+
const batch = yield* SqlBatchCommit;
|
|
50
|
+
|
|
51
|
+
const clock = yield* makeStorageClock(storage).pipe(
|
|
52
|
+
Effect.mapError(() => configurationError("Cannot configure the OAuth storage clock")),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
function mapped<Role extends StorageRole>(role: Role) {
|
|
56
|
+
const table = storage.tables[role];
|
|
57
|
+
|
|
58
|
+
if (table === undefined) throw configurationError(`Missing ${role} mapping`);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
table,
|
|
62
|
+
// Storage validation owns these logical column keys.
|
|
63
|
+
...(Object.fromEntries(Object.keys(storageTables[role].columns).map((key) => [key, key])) as {
|
|
64
|
+
readonly [Key in keyof (typeof storageTables)[Role]["columns"]]: Key;
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const s = storage.subjects;
|
|
70
|
+
const subject = tables(s.table);
|
|
71
|
+
const credential = mapped("oauthCredentials");
|
|
72
|
+
const credentials = tables(credential.table);
|
|
73
|
+
const authority = mapped("credentials");
|
|
74
|
+
const factors = tables(authority.table);
|
|
75
|
+
const now = tables.expression(clock.engineNowMillis);
|
|
76
|
+
const policies = new Map<string, M.OAuthConnectedPolicy>();
|
|
77
|
+
|
|
78
|
+
for (const feature of features) {
|
|
79
|
+
if (feature.connected === undefined) continue;
|
|
80
|
+
|
|
81
|
+
const policy = yield* M.snapshotOAuth(M.OAuthConnectedPolicy, feature.connected).pipe(
|
|
82
|
+
Effect.mapError(() => configurationError(`Invalid OAuth policy for ${feature.moduleId}`)),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const previous = policies.get(feature.moduleId);
|
|
86
|
+
|
|
87
|
+
if (previous !== undefined && encodePolicy(previous) !== encodePolicy(policy))
|
|
88
|
+
return yield* configurationError(`Conflicting OAuth policies for ${feature.moduleId}`);
|
|
89
|
+
policies.set(feature.moduleId, policy);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const shared = {
|
|
93
|
+
subject: {
|
|
94
|
+
table: s.table,
|
|
95
|
+
id: s.id,
|
|
96
|
+
status: s.status,
|
|
97
|
+
securityRevision: s.securityRevision,
|
|
98
|
+
isActiveStatus: (value: unknown) => Object.is(value, s.activeValue),
|
|
99
|
+
activeCondition: sql`${subject.column(s.status)} = ${subject.value(s.status, s.activeValue)}`,
|
|
100
|
+
decodeAuthenticationRequirement: s.requirements,
|
|
101
|
+
},
|
|
102
|
+
subjectId: { toSubject: s.toSubject, toNative: s.toNative, equals: Object.is },
|
|
103
|
+
ownership: {
|
|
104
|
+
...mapped("oauthIdentities"),
|
|
105
|
+
decodeSubjectId: (row: Record<string, unknown>) => row.subjectId,
|
|
106
|
+
encodeInsert: () => ({}),
|
|
107
|
+
},
|
|
108
|
+
credential: {
|
|
109
|
+
...credential,
|
|
110
|
+
status: "active",
|
|
111
|
+
isActiveStatus: (value: unknown) => value === true,
|
|
112
|
+
activeCondition: sql`${credentials.column("active")} = ${credentials.value("active", true)}`,
|
|
113
|
+
},
|
|
114
|
+
authority: {
|
|
115
|
+
...authority,
|
|
116
|
+
status: "active",
|
|
117
|
+
isActiveStatus: (value: unknown) => value === true,
|
|
118
|
+
activeCondition: sql`${factors.column("active")} = ${factors.value("active", true)}`,
|
|
119
|
+
},
|
|
120
|
+
clock,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function policyCondition(input: OAuthConnectedPolicyInput<unknown>): Fragment {
|
|
124
|
+
let moduleId: string;
|
|
125
|
+
|
|
126
|
+
switch (input.kind) {
|
|
127
|
+
case "sign-in":
|
|
128
|
+
moduleId = input.credential.moduleId;
|
|
129
|
+
break;
|
|
130
|
+
case "action":
|
|
131
|
+
moduleId = input.authorization.challenge.moduleId;
|
|
132
|
+
break;
|
|
133
|
+
case "metadata":
|
|
134
|
+
case "use":
|
|
135
|
+
moduleId = input.authorization.moduleId;
|
|
136
|
+
}
|
|
137
|
+
const policy = policies.get(moduleId);
|
|
138
|
+
|
|
139
|
+
if (policy === undefined) return sql`false`;
|
|
140
|
+
const grant = input.grant;
|
|
141
|
+
|
|
142
|
+
if (grant !== undefined) {
|
|
143
|
+
if (grant.moduleId !== moduleId || grant.subjectId !== input.revision.subjectId)
|
|
144
|
+
return sql`false`;
|
|
145
|
+
// Disconnect must remain possible after a profile is retired or removed.
|
|
146
|
+
if (input.kind !== "action" || input.operation !== "disconnect") {
|
|
147
|
+
const profile = policy.profiles.find(
|
|
148
|
+
(value) => value.key === grant.configuration.profile.key && value.issuance === "active",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
profile === undefined ||
|
|
153
|
+
encodeProfile(profile) !==
|
|
154
|
+
encodeProfile({
|
|
155
|
+
...grant.configuration.profile,
|
|
156
|
+
issuance: "active",
|
|
157
|
+
})
|
|
158
|
+
)
|
|
159
|
+
return sql`false`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const current = sql`exists(select 1 from ${subject.name} where ${subject.column(s.id)} = ${subject.value(s.id, input.subjectId)} and ${shared.subject.activeCondition} and ${exactSqlText(sql, subject.column(s.securityRevision), subject.value(s.securityRevision, input.revision.securityRevision))})`;
|
|
164
|
+
|
|
165
|
+
if (input.kind === "sign-in")
|
|
166
|
+
return features.some((feature) => feature.moduleId === moduleId && feature.signIn)
|
|
167
|
+
? current
|
|
168
|
+
: sql`false`;
|
|
169
|
+
if (input.kind !== "action") {
|
|
170
|
+
// Mutable application policy shares the subject's guarded security revision.
|
|
171
|
+
return input.authorization.policyRevision === input.revision.securityRevision
|
|
172
|
+
? current
|
|
173
|
+
: sql`false`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const { authorization } = input;
|
|
177
|
+
const { challenge, evidence, requirement } = authorization;
|
|
178
|
+
const action = input.operation === "settle" ? "connected-complete" : "connected-disconnect";
|
|
179
|
+
|
|
180
|
+
if (
|
|
181
|
+
challenge.action !== action ||
|
|
182
|
+
!sameRevision(challenge.revision, input.revision) ||
|
|
183
|
+
!sameRevision(evidence.revision, input.revision) ||
|
|
184
|
+
evidence.flowId !== AuthenticationFlowId.make(challenge.flowId) ||
|
|
185
|
+
evidence.bindingDigest !== challenge.bindingDigest ||
|
|
186
|
+
!satisfies(evidence.proofs, requirement) ||
|
|
187
|
+
evidence.proofs.some(
|
|
188
|
+
(proof) =>
|
|
189
|
+
!input.revision.credentials.some((entry) => entry.credentialId === proof.credentialId),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return sql`false`;
|
|
193
|
+
|
|
194
|
+
const maximumAge = Math.min(requirement.maximumAgeMillis, policy.maximumEvidenceAgeMillis);
|
|
195
|
+
const verified = evidence.proofs.map((proof) => DateTime.toEpochMillis(proof.verifiedAt));
|
|
196
|
+
|
|
197
|
+
if (authorization.source._tag === "Session")
|
|
198
|
+
verified.push(DateTime.toEpochMillis(authorization.source.authenticatedAt));
|
|
199
|
+
|
|
200
|
+
return sql.and([
|
|
201
|
+
current,
|
|
202
|
+
sql`${now} < ${authorization.validUntilMillis}`,
|
|
203
|
+
...verified.map((instant) => sql`${now} >= ${instant} and ${now} < ${instant + maximumAge}`),
|
|
204
|
+
]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let context: Context.Context<never> = Context.empty();
|
|
208
|
+
|
|
209
|
+
if (features.some((feature) => feature.signIn)) {
|
|
210
|
+
const mapping: OAuthNativeReadMapping = {
|
|
211
|
+
...shared,
|
|
212
|
+
flow: { ...mapped("oauthSignInFlows"), encodeInsert: () => ({}) },
|
|
213
|
+
constraints: requiredOAuthSignInConstraints,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const services = yield* makeNativeOAuthSignInServices(tables, mapping, batch).pipe(
|
|
217
|
+
Effect.mapError(() => configurationError("Cannot configure OAuth sign-in persistence")),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
context = Context.add(context, M.OAuthSignInPersistence, services.oauthSignInPersistence);
|
|
221
|
+
}
|
|
222
|
+
if (policies.size > 0) {
|
|
223
|
+
const job = { ...mapped("oauthConnectedRevocations"), encodeInsert: () => ({}) };
|
|
224
|
+
const grant = { ...mapped("oauthConnectedGrants"), encodeInsert: () => ({}) };
|
|
225
|
+
|
|
226
|
+
const mapping: OAuthNativeConnectedMapping = {
|
|
227
|
+
...shared,
|
|
228
|
+
flow: { ...mapped("oauthConnectedFlows"), encodeInsert: () => ({}) },
|
|
229
|
+
grant,
|
|
230
|
+
policy: { condition: policyCondition },
|
|
231
|
+
constraints: requiredOAuthConnectedConstraints,
|
|
232
|
+
revocation: {
|
|
233
|
+
mode: "provider",
|
|
234
|
+
job,
|
|
235
|
+
retentionMillis: revocationRetentionMillis,
|
|
236
|
+
constraints: requiredOAuthConnectedRevocationConstraints,
|
|
237
|
+
},
|
|
238
|
+
otherReferences: ({ identityKey, subjectId }) =>
|
|
239
|
+
sql`exists(select 1 from ${credentials.name} where ${credentials.column("subjectId")} = ${credentials.value("subjectId", subjectId)} and ${exactSqlText(sql, credentials.column("identityKey"), credentials.value("identityKey", identityKey))})`,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const connected = yield* makeNativeOAuthConnectedServices(tables, mapping, batch).pipe(
|
|
243
|
+
Effect.mapError(() => configurationError("Cannot configure OAuth connected persistence")),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const revocations = yield* makeNativeOAuthRevocationServices(
|
|
247
|
+
tables,
|
|
248
|
+
{
|
|
249
|
+
...mapping,
|
|
250
|
+
job,
|
|
251
|
+
constraints: requiredOAuthConnectedRevocationConstraints,
|
|
252
|
+
},
|
|
253
|
+
batch,
|
|
254
|
+
).pipe(
|
|
255
|
+
Effect.mapError(() => configurationError("Cannot configure OAuth revocation persistence")),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
context = context.pipe(
|
|
259
|
+
Context.add(M.OAuthConnectedPersistence, {
|
|
260
|
+
...connected.oauthConnectedPersistence,
|
|
261
|
+
read: (input) =>
|
|
262
|
+
connected.oauthConnectedPersistence
|
|
263
|
+
.read(input)
|
|
264
|
+
.pipe(
|
|
265
|
+
Effect.map((snapshot) =>
|
|
266
|
+
snapshot === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: { ...snapshot, policyRevision: snapshot.revision.securityRevision },
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
}),
|
|
272
|
+
Context.add(M.OAuthConnectedRevocations, revocations.oauthConnectedRevocations),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return context;
|
|
277
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/* oxlint-disable no-explicit-any -- physical mapping generics are erased only at this shared adapter boundary. */
|
|
2
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
2
3
|
import * as M from "@yielded/auth/Passkey";
|
|
3
4
|
import { Effect, Schema } from "effect";
|
|
4
5
|
import { SqlClient } from "effect/sql";
|
|
@@ -9,6 +10,7 @@ import { sqlMapping, type NativeSqlTables } from "./native-sql-table";
|
|
|
9
10
|
import { makePasskeyNativeFlow } from "./passkey-native-flow";
|
|
10
11
|
import { passkeyCredentialKey } from "./passkey-policy";
|
|
11
12
|
import { exactSqlText } from "./sql-change";
|
|
13
|
+
import { canJoinTextColumns } from "./storage-validation";
|
|
12
14
|
import type { TableModel } from "./table-model";
|
|
13
15
|
|
|
14
16
|
// Physical expression types are validated by NativeSqlTables at this boundary.
|
|
@@ -226,75 +228,79 @@ export const makePasskeyNativeReadState = Effect.fnUntraced(function* (
|
|
|
226
228
|
return sql`(select * from ${table.name} where ${active(kind)}) as ${sql(alias)}`;
|
|
227
229
|
};
|
|
228
230
|
|
|
231
|
+
const joinedIds = yield* canJoinTextColumns([
|
|
232
|
+
subject.unencodedTextColumn?.(read.subject.id),
|
|
233
|
+
credential.unencodedTextColumn?.(read.credential.subjectId),
|
|
234
|
+
factor.unencodedTextColumn?.(read.authority.subjectId),
|
|
235
|
+
]).pipe(Effect.mapError(() => M.PasskeyUnavailable.make({})));
|
|
236
|
+
|
|
229
237
|
const lookup = Effect.fnUntraced(function* (rpId: string, protocolCredentialId: string) {
|
|
230
238
|
const key = yield* passkeyCredentialKey(rpId, protocolCredentialId);
|
|
239
|
+
let nativeId: unknown;
|
|
231
240
|
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
factor.unencodedTextColumn?.(read.authority.subjectId) !== undefined
|
|
236
|
-
) {
|
|
237
|
-
const s = joinedSubject,
|
|
238
|
-
c = joinedCredential,
|
|
239
|
-
f = joinedFactor;
|
|
240
|
-
|
|
241
|
-
const rows =
|
|
242
|
-
yield* sql<PasskeyNativeRow>`select ${s.fields("s_")}, ${c.fields("c_")}, ${f.fields("f_")}
|
|
243
|
-
from ${selected("credential", "passkey_credential")}
|
|
244
|
-
join ${selected("subject", "passkey_subject")} on ${exactSqlText(sql, s.column(read.subject.id), c.column(read.credential.subjectId))}
|
|
245
|
-
left join ${selected("authority", "passkey_factor")} on ${exactSqlText(sql, f.column(read.authority.subjectId), c.column(read.credential.subjectId))}
|
|
246
|
-
where ${exactSqlText(sql, c.column(read.credential.credentialKey), c.value(read.credential.credentialKey, key))} limit 65`;
|
|
241
|
+
if (!joinedIds) {
|
|
242
|
+
const found =
|
|
243
|
+
yield* sql<PasskeyNativeRow>`select ${credential.fields("c_")} from ${credential.name} where ${exactSqlText(sql, credential.column(read.credential.credentialKey), credential.value(read.credential.credentialKey, key))} and ${active("credential")}`;
|
|
247
244
|
|
|
248
|
-
if (
|
|
249
|
-
const row =
|
|
250
|
-
const subjectRow = s.decode(rows[0]!, "s_");
|
|
251
|
-
const owner = read.credential.decodeSubjectId(row);
|
|
245
|
+
if (found.length !== 1) return undefined;
|
|
246
|
+
const row = credential.decode(found[0]!, "c_");
|
|
252
247
|
|
|
253
248
|
invariant(
|
|
254
249
|
row[read.credential.rpId] === rpId &&
|
|
255
250
|
row[read.credential.protocolCredentialId] === protocolCredentialId,
|
|
256
251
|
);
|
|
257
|
-
|
|
258
|
-
rows.every((selected) => {
|
|
259
|
-
const other = c.decode(selected, "c_");
|
|
260
|
-
const subjectValue = s.decode(selected, "s_");
|
|
261
|
-
|
|
262
|
-
return (
|
|
263
|
-
sameId(owner, read.credential.decodeSubjectId(other)) &&
|
|
264
|
-
sameId(owner, read.subject.decodeId(subjectValue)) &&
|
|
265
|
-
other[read.credential.credentialId] === row[read.credential.credentialId] &&
|
|
266
|
-
other[read.credential.credentialRevision] === row[read.credential.credentialRevision] &&
|
|
267
|
-
subjectValue[read.subject.securityRevision] ===
|
|
268
|
-
subjectRow[read.subject.securityRevision]
|
|
269
|
-
);
|
|
270
|
-
}),
|
|
271
|
-
);
|
|
272
|
-
|
|
273
|
-
return yield* decodeCredential(
|
|
274
|
-
row,
|
|
275
|
-
revision(
|
|
276
|
-
subjectRow,
|
|
277
|
-
rows.map((row) => f.decode(row, "f_")),
|
|
278
|
-
),
|
|
279
|
-
subjectRow,
|
|
280
|
-
);
|
|
252
|
+
nativeId = read.credential.decodeSubjectId(row);
|
|
281
253
|
}
|
|
282
254
|
|
|
255
|
+
const s = joinedSubject,
|
|
256
|
+
c = joinedCredential,
|
|
257
|
+
f = joinedFactor;
|
|
258
|
+
|
|
259
|
+
const bindOwner = (table: typeof subject, key: string) =>
|
|
260
|
+
joinedIds
|
|
261
|
+
? exactSqlText(sql, table.column(key), c.column(read.credential.subjectId))
|
|
262
|
+
: sql`${table.column(key)} = ${table.value(key, nativeId)}`;
|
|
263
|
+
|
|
283
264
|
const rows =
|
|
284
|
-
yield* sql<PasskeyNativeRow>`select ${
|
|
265
|
+
yield* sql<PasskeyNativeRow>`select ${s.fields("s_")}, ${c.fields("c_")}, ${f.fields("f_")}
|
|
266
|
+
from ${selected("credential", "passkey_credential")}
|
|
267
|
+
join ${selected("subject", "passkey_subject")} on ${bindOwner(s, read.subject.id)}
|
|
268
|
+
left join ${selected("authority", "passkey_factor")} on ${bindOwner(f, read.authority.subjectId)}
|
|
269
|
+
where ${exactSqlText(sql, c.column(read.credential.credentialKey), c.value(read.credential.credentialKey, key))}${joinedIds ? sql`` : sql` and ${bindOwner(c, read.credential.subjectId)}`} limit 65`;
|
|
285
270
|
|
|
286
|
-
if (rows.length
|
|
287
|
-
const row =
|
|
271
|
+
if (rows.length === 0 || rows.length > 64) return undefined;
|
|
272
|
+
const row = c.decode(rows[0]!, "c_");
|
|
273
|
+
const subjectRow = s.decode(rows[0]!, "s_");
|
|
274
|
+
const owner = read.credential.decodeSubjectId(row);
|
|
288
275
|
|
|
289
276
|
invariant(
|
|
290
|
-
|
|
277
|
+
(joinedIds || sameId(owner, nativeId)) &&
|
|
278
|
+
row[read.credential.rpId] === rpId &&
|
|
291
279
|
row[read.credential.protocolCredentialId] === protocolCredentialId,
|
|
292
280
|
);
|
|
293
|
-
|
|
281
|
+
invariant(
|
|
282
|
+
rows.every((selected) => {
|
|
283
|
+
const other = c.decode(selected, "c_");
|
|
284
|
+
const subjectValue = s.decode(selected, "s_");
|
|
294
285
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
286
|
+
return (
|
|
287
|
+
sameId(owner, read.credential.decodeSubjectId(other)) &&
|
|
288
|
+
sameId(owner, read.subject.decodeId(subjectValue)) &&
|
|
289
|
+
other[read.credential.credentialId] === row[read.credential.credentialId] &&
|
|
290
|
+
other[read.credential.credentialRevision] === row[read.credential.credentialRevision] &&
|
|
291
|
+
subjectValue[read.subject.securityRevision] === subjectRow[read.subject.securityRevision]
|
|
292
|
+
);
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return yield* decodeCredential(
|
|
297
|
+
row,
|
|
298
|
+
revision(
|
|
299
|
+
subjectRow,
|
|
300
|
+
rows.map((row) => f.decode(row, "f_")),
|
|
301
|
+
),
|
|
302
|
+
subjectRow,
|
|
303
|
+
);
|
|
298
304
|
});
|
|
299
305
|
|
|
300
306
|
return {
|
|
@@ -326,9 +332,10 @@ export const makePasskeyNativeState = Effect.fnUntraced(function* (
|
|
|
326
332
|
mapping: PasskeyNativeMapping,
|
|
327
333
|
) {
|
|
328
334
|
const state = yield* makePasskeyNativeReadState(tables, mapping.read, mapping.clock);
|
|
335
|
+
const clockPolicy = yield* AuthenticationClock;
|
|
329
336
|
const ceremony = yield* makePasskeyNativeFlow(tables, mapping);
|
|
330
337
|
|
|
331
|
-
return { ...state, mapping, ceremony, flow: ceremony.flow, access: ceremony.access };
|
|
338
|
+
return { ...state, clockPolicy, mapping, ceremony, flow: ceremony.flow, access: ceremony.access };
|
|
332
339
|
});
|
|
333
340
|
|
|
334
341
|
export type PasskeyNativeReadState = Effect.Success<ReturnType<typeof makePasskeyNativeReadState>>;
|
|
@@ -334,7 +334,7 @@ export const makePasskeyNativeManagement = (
|
|
|
334
334
|
|
|
335
335
|
const requirement = yield* mapping.write.policy.requirement(current.row, "enroll-begin");
|
|
336
336
|
|
|
337
|
-
const authority = assessPasskeyAction(
|
|
337
|
+
const authority = yield* assessPasskeyAction(
|
|
338
338
|
{
|
|
339
339
|
moduleId: mapping.moduleId,
|
|
340
340
|
revision: current.revision,
|
|
@@ -487,7 +487,7 @@ export const makePasskeyNativeManagement = (
|
|
|
487
487
|
return yield* preparePasskeyNative({ _tag: "Rejected" }, prepare);
|
|
488
488
|
const requirement = yield* mapping.write.policy.requirement(current.row, "remove");
|
|
489
489
|
|
|
490
|
-
const authority = assessPasskeyAction(
|
|
490
|
+
const authority = yield* assessPasskeyAction(
|
|
491
491
|
{
|
|
492
492
|
moduleId: mapping.moduleId,
|
|
493
493
|
revision: current.revision,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CurrentCommitJournal } from "@yielded/auth/Hooks";
|
|
2
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
2
3
|
import * as M from "@yielded/auth/Passkey";
|
|
3
4
|
import { Crypto, Effect, Option, Schema } from "effect";
|
|
4
5
|
import { SqlError } from "effect/sql/SqlError";
|
|
@@ -66,9 +67,10 @@ const services = Effect.fnUntraced(function* (
|
|
|
66
67
|
effect: Effect.Effect<A, E, R>,
|
|
67
68
|
mode: "transaction" | "statement" = "transaction",
|
|
68
69
|
) =>
|
|
69
|
-
batch === undefined
|
|
70
|
+
(batch === undefined
|
|
70
71
|
? executor.run(provide(effect), mode)
|
|
71
|
-
: executor.batch(provide(effect)).pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
72
|
+
: executor.batch(provide(effect)).pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
73
|
+
).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
|
|
72
74
|
|
|
73
75
|
const advisory = <A, E, R>(effect: Effect.Effect<A, E, R>) => executor.read(provide(effect));
|
|
74
76
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
1
2
|
import {
|
|
2
3
|
PasskeyUnavailable,
|
|
3
4
|
type PasskeyCeremony,
|
|
@@ -154,10 +155,11 @@ export interface PasskeyActionFacts {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
/** Reject stale bindings before evaluating application requirements. */
|
|
157
|
-
export const validPasskeyActionEvidence = (
|
|
158
|
+
export const validPasskeyActionEvidence = Effect.fnUntraced(function* (
|
|
158
159
|
facts: Pick<PasskeyActionFacts, "moduleId" | "revision" | "expected" | "nowMillis">,
|
|
159
160
|
authorization: PasskeyActionAuthorization,
|
|
160
|
-
)
|
|
161
|
+
): Effect.fn.Return<boolean> {
|
|
162
|
+
const { futureToleranceMillis } = yield* AuthenticationClock;
|
|
161
163
|
const { challenge, evidence } = authorization;
|
|
162
164
|
const { expected, revision, nowMillis: now } = facts;
|
|
163
165
|
|
|
@@ -190,23 +192,25 @@ export const validPasskeyActionEvidence = (
|
|
|
190
192
|
if (
|
|
191
193
|
evidence.proofs.some(
|
|
192
194
|
(proof) =>
|
|
193
|
-
DateTime.toEpochMillis(proof.verifiedAt)
|
|
195
|
+
DateTime.toEpochMillis(proof.verifiedAt) - now > futureToleranceMillis ||
|
|
194
196
|
!evidence.revision.credentials.some((item) => item.credentialId === proof.credentialId),
|
|
195
197
|
)
|
|
196
198
|
)
|
|
197
199
|
return false;
|
|
198
200
|
|
|
199
201
|
return true;
|
|
200
|
-
};
|
|
202
|
+
});
|
|
201
203
|
|
|
202
204
|
/** SQL policy predicates remain native. This authorizes the exact evidence and
|
|
203
205
|
* returns the deadline every backend must retain through its final commit. */
|
|
204
|
-
export const assessPasskeyAction = (
|
|
206
|
+
export const assessPasskeyAction = Effect.fnUntraced(function* (
|
|
205
207
|
facts: PasskeyActionFacts,
|
|
206
208
|
authorization: PasskeyActionAuthorization,
|
|
207
209
|
policy: PasskeyManagementPolicy,
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
+
): Effect.fn.Return<
|
|
211
|
+
{ readonly notBeforeMillis: number; readonly expiresBeforeMillis: number } | undefined
|
|
212
|
+
> {
|
|
213
|
+
if (!(yield* validPasskeyActionEvidence(facts, authorization))) return undefined;
|
|
210
214
|
const { evidence } = authorization;
|
|
211
215
|
const now = facts.nowMillis;
|
|
212
216
|
let expiresBefore = Infinity;
|
|
@@ -219,7 +223,7 @@ export const assessPasskeyAction = (
|
|
|
219
223
|
const age = Math.min(policy.maximumEvidenceAgeMillis, requirement.maximumAgeMillis);
|
|
220
224
|
|
|
221
225
|
const fresh = evidence.proofs.filter(
|
|
222
|
-
(proof) => now - DateTime.toEpochMillis(proof.verifiedAt) < age,
|
|
226
|
+
(proof) => Math.max(0, now - DateTime.toEpochMillis(proof.verifiedAt)) < age,
|
|
223
227
|
);
|
|
224
228
|
|
|
225
229
|
const factors = new Set(fresh.flatMap((proof) => proof.factors));
|
|
@@ -243,4 +247,4 @@ export const assessPasskeyAction = (
|
|
|
243
247
|
}
|
|
244
248
|
|
|
245
249
|
return { notBeforeMillis: now, expiresBeforeMillis: expiresBefore };
|
|
246
|
-
};
|
|
250
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CurrentCommitJournal, type LifecycleHooks } from "@yielded/auth/Hooks";
|
|
2
2
|
import type { LoginIdentifier } from "@yielded/auth/Identity";
|
|
3
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
3
4
|
import * as P from "@yielded/auth/Password";
|
|
4
5
|
import { ProofRedemptionInput } from "@yielded/auth/Proofs";
|
|
5
6
|
import type { SubjectId } from "@yielded/auth/Schema";
|
|
@@ -52,6 +53,7 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
52
53
|
SqlClient | LifecycleHooks | Crypto.Crypto | SqlBatchCommit
|
|
53
54
|
> {
|
|
54
55
|
const batch = yield* SqlBatchCommit;
|
|
56
|
+
const clockPolicy = yield* AuthenticationClock;
|
|
55
57
|
const sql = (yield* SqlClient).withoutTransforms();
|
|
56
58
|
const executor = yield* makeSqlCommitExecutor(unavailable);
|
|
57
59
|
const crypto = yield* Crypto.Crypto;
|
|
@@ -87,11 +89,12 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
87
89
|
sql`${table.column(key)} = ${table.value(key, nativeId)}`;
|
|
88
90
|
|
|
89
91
|
const run = <A, E, R>(work: Effect.Effect<A, E, R>) =>
|
|
90
|
-
batch === undefined
|
|
92
|
+
(batch === undefined
|
|
91
93
|
? executor.run(work.pipe(Effect.provideService(Crypto.Crypto, crypto)))
|
|
92
94
|
: executor
|
|
93
95
|
.batch(work.pipe(Effect.provideService(Crypto.Crypto, crypto)))
|
|
94
|
-
.pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
96
|
+
.pipe(Effect.provideService(SqlBatchCommit, batch))
|
|
97
|
+
).pipe(Effect.provideService(AuthenticationClock, clockPolicy));
|
|
95
98
|
|
|
96
99
|
const read = Effect.fnUntraced(function* (
|
|
97
100
|
moduleId: string,
|
|
@@ -346,7 +349,9 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
346
349
|
verifierVersion !== input.credential?.verifierVersion,
|
|
347
350
|
);
|
|
348
351
|
|
|
349
|
-
const freshUntil = (
|
|
352
|
+
const freshUntil = Effect.fnUntraced(function* (
|
|
353
|
+
requirement: AuthenticationRequirement,
|
|
354
|
+
): Effect.fn.Return<number | undefined> {
|
|
350
355
|
const deadlines = [
|
|
351
356
|
...new Set(
|
|
352
357
|
input.authorization.evidence.proofs.map(
|
|
@@ -357,10 +362,13 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
357
362
|
.filter((time) => time > state.now)
|
|
358
363
|
.sort((a, b) => a - b);
|
|
359
364
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
for (const time of deadlines) {
|
|
366
|
+
if (
|
|
367
|
+
!(yield* passwordEvidenceSatisfiedAt(input.authorization.evidence, requirement, time))
|
|
368
|
+
)
|
|
369
|
+
return time;
|
|
370
|
+
}
|
|
371
|
+
});
|
|
364
372
|
|
|
365
373
|
if (batch !== undefined)
|
|
366
374
|
yield* guardSqlitePolicy({
|
|
@@ -370,8 +378,8 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
|
|
|
370
378
|
values: state.policyValues,
|
|
371
379
|
});
|
|
372
380
|
|
|
373
|
-
const requestedDeadline = freshUntil(input.authorization.requirement),
|
|
374
|
-
currentDeadline = freshUntil(requirement);
|
|
381
|
+
const requestedDeadline = yield* freshUntil(input.authorization.requirement),
|
|
382
|
+
currentDeadline = yield* freshUntil(requirement);
|
|
375
383
|
|
|
376
384
|
ensure(requestedDeadline !== undefined && currentDeadline !== undefined);
|
|
377
385
|
const deadline = Math.min(requestedDeadline, currentDeadline);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LoginIdentifier } from "@yielded/auth/Identity";
|
|
2
|
+
import { AuthenticationClock } from "@yielded/auth/Operations";
|
|
2
3
|
import {
|
|
3
4
|
PasswordUnavailable,
|
|
4
5
|
snapshotPasswordCredential,
|
|
@@ -47,11 +48,13 @@ export const samePasswordCredential = (
|
|
|
47
48
|
samePasswordIdentifier(left.identifier, right.identifier) &&
|
|
48
49
|
Redacted.value(left.verifier) === Redacted.value(right.verifier);
|
|
49
50
|
|
|
50
|
-
export const passwordEvidenceSatisfiedAt = (
|
|
51
|
+
export const passwordEvidenceSatisfiedAt = Effect.fnUntraced(function* (
|
|
51
52
|
evidence: AuthenticationEvidence,
|
|
52
53
|
requirement: AuthenticationRequirement,
|
|
53
54
|
now: number,
|
|
54
|
-
): boolean
|
|
55
|
+
): Effect.fn.Return<boolean> {
|
|
56
|
+
const { futureToleranceMillis } = yield* AuthenticationClock;
|
|
57
|
+
|
|
55
58
|
if (evidence.proofs.length > 64 || evidence.revision.credentials.length > 64) return false;
|
|
56
59
|
const revisions = new Map<string, string>();
|
|
57
60
|
|
|
@@ -60,12 +63,17 @@ export const passwordEvidenceSatisfiedAt = (
|
|
|
60
63
|
revisions.set(item.credentialId, item.revision);
|
|
61
64
|
}
|
|
62
65
|
if (evidence.proofs.some((proof) => !revisions.has(proof.credentialId))) return false;
|
|
63
|
-
if (
|
|
66
|
+
if (
|
|
67
|
+
evidence.proofs.some(
|
|
68
|
+
(proof) => DateTime.toEpochMillis(proof.verifiedAt) - now > futureToleranceMillis,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return false;
|
|
64
72
|
|
|
65
73
|
const eligible = evidence.proofs.filter((proof) => {
|
|
66
|
-
const age = now - DateTime.toEpochMillis(proof.verifiedAt);
|
|
74
|
+
const age = Math.max(0, now - DateTime.toEpochMillis(proof.verifiedAt));
|
|
67
75
|
|
|
68
|
-
return age
|
|
76
|
+
return age < requirement.maximumAgeMillis;
|
|
69
77
|
});
|
|
70
78
|
|
|
71
79
|
const factors = new Set(eligible.flatMap((proof) => proof.factors));
|
|
@@ -81,7 +89,7 @@ export const passwordEvidenceSatisfiedAt = (
|
|
|
81
89
|
(!alternative.phishingResistant || proof.phishingResistant),
|
|
82
90
|
),
|
|
83
91
|
);
|
|
84
|
-
};
|
|
92
|
+
});
|
|
85
93
|
|
|
86
94
|
export const snapshotPasswordMutation = Effect.fn("PasswordPersistence.snapshotMutation")(
|
|
87
95
|
function* (input: PasswordMutationInput) {
|
|
@@ -272,8 +280,8 @@ export const validatePasswordMutation = Effect.fnUntraced(function* (
|
|
|
272
280
|
const requirement = yield* current.requirement;
|
|
273
281
|
|
|
274
282
|
if (
|
|
275
|
-
!passwordEvidenceSatisfiedAt(evidence, input.authorization.requirement, now) ||
|
|
276
|
-
!passwordEvidenceSatisfiedAt(evidence, requirement, now)
|
|
283
|
+
!(yield* passwordEvidenceSatisfiedAt(evidence, input.authorization.requirement, now)) ||
|
|
284
|
+
!(yield* passwordEvidenceSatisfiedAt(evidence, requirement, now))
|
|
277
285
|
)
|
|
278
286
|
return undefined;
|
|
279
287
|
if (
|