@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
|
@@ -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
|
+
});
|
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
requiredPasskeyPersistenceConstraints,
|
|
15
15
|
} from "./models/passkey-model";
|
|
16
16
|
import type { NativeSqlTables } from "./native-sql-table";
|
|
17
|
-
import { makeNativePasskeyServices } from "./passkey-native";
|
|
17
|
+
import { makeBatchPasskeyServices, makeNativePasskeyServices } from "./passkey-native";
|
|
18
18
|
import type { PasskeyNativeMapping, PasskeyNativeRead } from "./passkey-native-state";
|
|
19
19
|
import {
|
|
20
20
|
makePasskeyNativeManagement,
|
|
21
21
|
type NativePasskeyManagementMapping,
|
|
22
22
|
} from "./passkey-native-write";
|
|
23
23
|
import { anySqlCondition } from "./sql-change";
|
|
24
|
+
import { SqlBatchCommit } from "./sql-commit";
|
|
24
25
|
import { storageTables, type StorageRole } from "./storage-tables";
|
|
25
26
|
|
|
26
27
|
export interface ComposedPasskeyInput {
|
|
@@ -255,6 +256,8 @@ const mappings = Effect.fnUntraced(function* (
|
|
|
255
256
|
/** Managed and mapped tables feed the same native passkey statements. */
|
|
256
257
|
export const makeManagedPasskeys = Effect.fnUntraced(
|
|
257
258
|
function* (input: ComposedPasskeyInput, tables: NativeSqlTables) {
|
|
259
|
+
const batch = yield* SqlBatchCommit;
|
|
260
|
+
|
|
258
261
|
const policies = yield* Effect.forEach(input.features, (feature) =>
|
|
259
262
|
Effect.map(feature.policy, (policy) => ({ feature, policy })),
|
|
260
263
|
);
|
|
@@ -275,7 +278,10 @@ export const makeManagedPasskeys = Effect.fnUntraced(
|
|
|
275
278
|
|
|
276
279
|
for (const feature of input.features) {
|
|
277
280
|
const selected = feature.management ? mapping.management(feature) : mapping.base(feature);
|
|
278
|
-
|
|
281
|
+
|
|
282
|
+
const base = yield* batch === undefined
|
|
283
|
+
? makeNativePasskeyServices(tables, selected)
|
|
284
|
+
: makeBatchPasskeyServices(tables, selected);
|
|
279
285
|
|
|
280
286
|
services.set(feature.moduleId, base);
|
|
281
287
|
if (feature.management)
|
|
@@ -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
|
+
});
|