@byline/admin 6.7.2 → 6.9.0
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/dist/fields/blocks/blocks-field.js +33 -3
- package/dist/fields/blocks/blocks-field.module.js +1 -0
- package/dist/fields/blocks/blocks-field_module.css +19 -0
- package/dist/forms/form-page-chrome.d.ts +3 -2
- package/dist/forms/form-page-chrome.js +13 -1
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +23 -17
- package/dist/forms/unavailable-blocks.d.ts +15 -0
- package/dist/forms/unavailable-blocks.js +38 -0
- package/dist/modules/admin-account/components/change-password.js +1 -1
- package/dist/modules/admin-account/components/container.js +18 -3
- package/dist/modules/admin-account/components/container.module.js +2 -0
- package/dist/modules/admin-account/components/container_module.css +7 -0
- package/dist/modules/admin-account/components/preferences.js +1 -1
- package/dist/modules/admin-account/components/update.js +1 -1
- package/dist/modules/admin-roles/components/create.js +1 -1
- package/dist/modules/admin-roles/components/permissions.js +1 -1
- package/dist/modules/admin-roles/components/update.js +1 -1
- package/dist/modules/admin-users/abilities.d.ts +1 -0
- package/dist/modules/admin-users/abilities.js +8 -1
- package/dist/modules/admin-users/components/create.js +1 -1
- package/dist/modules/admin-users/components/roles.js +1 -1
- package/dist/modules/admin-users/components/set-password.js +1 -1
- package/dist/modules/admin-users/components/update.js +1 -1
- package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
- package/dist/modules/auth/auth-maintenance-repository.js +1 -0
- package/dist/modules/auth/auth-maintenance.d.ts +11 -0
- package/dist/modules/auth/auth-maintenance.js +39 -0
- package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
- package/dist/modules/auth/components/passkey-browser.js +118 -0
- package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
- package/dist/modules/auth/components/passkey-manager.js +406 -0
- package/dist/modules/auth/components/passkey-manager.module.js +22 -0
- package/dist/modules/auth/components/passkey-manager_module.css +104 -0
- package/dist/modules/auth/components/sign-in-form.js +62 -3
- package/dist/modules/auth/components/sign-in-form.module.js +5 -1
- package/dist/modules/auth/components/sign-in-form_module.css +40 -0
- package/dist/modules/auth/index.d.ts +5 -0
- package/dist/modules/auth/index.js +3 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
- package/dist/modules/auth/jwt-session-provider.js +135 -94
- package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
- package/dist/modules/auth/native-passkey-provider.js +618 -0
- package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
- package/dist/modules/auth/passkey-rate-limiter.js +60 -0
- package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
- package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
- package/dist/modules/auth/passkey-storage-validation.js +59 -0
- package/dist/modules/auth/passkeys-repository.d.ts +96 -0
- package/dist/modules/auth/passkeys-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
- package/dist/services/admin-services-context.d.ts +1 -1
- package/dist/services/admin-services-types.d.ts +60 -0
- package/dist/store.d.ts +4 -0
- package/package.json +16 -9
- package/src/fields/blocks/blocks-field.module.css +23 -0
- package/src/fields/blocks/blocks-field.tsx +26 -2
- package/src/forms/form-page-chrome.tsx +19 -1
- package/src/forms/form-renderer-capabilities.test.tsx +6 -6
- package/src/forms/form-renderer.tsx +24 -15
- package/src/forms/unavailable-blocks.test.tsx +152 -0
- package/src/forms/unavailable-blocks.tsx +58 -0
- package/src/modules/admin-account/components/change-password.tsx +1 -1
- package/src/modules/admin-account/components/container.module.css +9 -0
- package/src/modules/admin-account/components/container.tsx +12 -3
- package/src/modules/admin-account/components/preferences.tsx +1 -1
- package/src/modules/admin-account/components/update.tsx +1 -1
- package/src/modules/admin-roles/components/create.tsx +1 -1
- package/src/modules/admin-roles/components/permissions.tsx +1 -1
- package/src/modules/admin-roles/components/update.tsx +1 -1
- package/src/modules/admin-users/abilities.ts +7 -0
- package/src/modules/admin-users/components/create.tsx +1 -1
- package/src/modules/admin-users/components/roles.tsx +1 -1
- package/src/modules/admin-users/components/set-password.tsx +1 -1
- package/src/modules/admin-users/components/update.tsx +1 -1
- package/src/modules/auth/auth-maintenance-repository.ts +26 -0
- package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
- package/src/modules/auth/auth-maintenance.ts +62 -0
- package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
- package/src/modules/auth/components/passkey-browser.ts +139 -0
- package/src/modules/auth/components/passkey-manager.module.css +100 -0
- package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
- package/src/modules/auth/components/passkey-manager.tsx +386 -0
- package/src/modules/auth/components/sign-in-form.module.css +37 -0
- package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
- package/src/modules/auth/components/sign-in-form.tsx +65 -4
- package/src/modules/auth/index.ts +15 -0
- package/src/modules/auth/jwt-session-provider.ts +116 -44
- package/src/modules/auth/native-passkey-provider.ts +710 -0
- package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
- package/src/modules/auth/passkey-rate-limiter.ts +76 -0
- package/src/modules/auth/passkey-storage-validation.ts +108 -0
- package/src/modules/auth/passkeys-repository.ts +97 -0
- package/src/modules/auth/refresh-tokens-repository.ts +1 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
- package/src/services/admin-services-context.tsx +2 -0
- package/src/services/admin-services-types.ts +60 -0
- package/src/store.ts +4 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { AuthError, AuthErrorCodes, ERR_FORBIDDEN, ERR_INVALID_CREDENTIALS, ERR_INVALID_TOKEN, ERR_SESSION_CHANGED } from "@byline/auth";
|
|
3
|
+
import { resolvePasskeyConfig } from "@byline/core";
|
|
4
|
+
import { passwordSignInSchema } from "@byline/core/validation";
|
|
5
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
|
6
|
+
import { v7, validate } from "uuid";
|
|
7
|
+
import { ADMIN_USERS_ABILITIES } from "../admin-users/abilities.js";
|
|
8
|
+
import { assertPasskeyBytes } from "./passkey-storage-validation.js";
|
|
9
|
+
import { verifyPassword } from "./password.js";
|
|
10
|
+
function _check_private_redeclaration(obj, privateCollection) {
|
|
11
|
+
if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
|
|
12
|
+
}
|
|
13
|
+
function _class_apply_descriptor_get(receiver, descriptor) {
|
|
14
|
+
if (descriptor.get) return descriptor.get.call(receiver);
|
|
15
|
+
return descriptor.value;
|
|
16
|
+
}
|
|
17
|
+
function _class_apply_descriptor_set(receiver, descriptor, value) {
|
|
18
|
+
if (descriptor.set) descriptor.set.call(receiver, value);
|
|
19
|
+
else {
|
|
20
|
+
if (!descriptor.writable) throw new TypeError("attempted to set read only private field");
|
|
21
|
+
descriptor.value = value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function _class_extract_field_descriptor(receiver, privateMap, action) {
|
|
25
|
+
if (!privateMap.has(receiver)) throw new TypeError("attempted to " + action + " private field on non-instance");
|
|
26
|
+
return privateMap.get(receiver);
|
|
27
|
+
}
|
|
28
|
+
function _class_private_field_get(receiver, privateMap) {
|
|
29
|
+
var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
|
|
30
|
+
return _class_apply_descriptor_get(receiver, descriptor);
|
|
31
|
+
}
|
|
32
|
+
function _class_private_field_init(obj, privateMap, value) {
|
|
33
|
+
_check_private_redeclaration(obj, privateMap);
|
|
34
|
+
privateMap.set(obj, value);
|
|
35
|
+
}
|
|
36
|
+
function _class_private_field_set(receiver, privateMap, value) {
|
|
37
|
+
var descriptor = _class_extract_field_descriptor(receiver, privateMap, "set");
|
|
38
|
+
_class_apply_descriptor_set(receiver, descriptor, value);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function _class_private_method_get(receiver, privateSet, fn) {
|
|
42
|
+
if (!privateSet.has(receiver)) throw new TypeError("attempted to get private field on non-instance");
|
|
43
|
+
return fn;
|
|
44
|
+
}
|
|
45
|
+
function _class_private_method_init(obj, privateSet) {
|
|
46
|
+
_check_private_redeclaration(obj, privateSet);
|
|
47
|
+
privateSet.add(obj);
|
|
48
|
+
}
|
|
49
|
+
const MAX_PENDING = 5;
|
|
50
|
+
const MAX_CREDENTIALS = 10;
|
|
51
|
+
const LIFETIME_MS = 300000;
|
|
52
|
+
var _trust = /*#__PURE__*/ new WeakMap(), _run = /*#__PURE__*/ new WeakSet(), _authenticationOptions = /*#__PURE__*/ new WeakSet(), _newAttempt = /*#__PURE__*/ new WeakSet(), _claimAuthenticated = /*#__PURE__*/ new WeakSet(), _unexpired = /*#__PURE__*/ new WeakSet(), _recheckAuthority = /*#__PURE__*/ new WeakSet(), _authorizeTarget = /*#__PURE__*/ new WeakSet(), _authorizeAction = /*#__PURE__*/ new WeakSet(), _capacity = /*#__PURE__*/ new WeakSet(), _grant = /*#__PURE__*/ new WeakSet(), _verifyAuthentication = /*#__PURE__*/ new WeakSet(), _applyVerifiedKey = /*#__PURE__*/ new WeakSet();
|
|
53
|
+
class NativePasskeyProvider {
|
|
54
|
+
recordAdmissionDenied(purpose, reason) {
|
|
55
|
+
this.deps.emit({
|
|
56
|
+
type: 'passkey_admission_denied',
|
|
57
|
+
purpose,
|
|
58
|
+
reason
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
signInOptions(_metadata) {
|
|
62
|
+
return _class_private_method_get(this, _run, run).call(this, 'sign-in-options', async ()=>{
|
|
63
|
+
const { raw, row } = _class_private_method_get(this, _newAttempt, newAttempt).call(this, 'sign-in');
|
|
64
|
+
const options = await _class_private_method_get(this, _authenticationOptions, authenticationOptions).call(this, row.challenge);
|
|
65
|
+
await this.deps.store.passkeys.createAttempt(row);
|
|
66
|
+
return {
|
|
67
|
+
ceremonyId: raw,
|
|
68
|
+
expiresAt: row.expires_at.toISOString(),
|
|
69
|
+
options
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
verifySignIn(args) {
|
|
74
|
+
return _class_private_method_get(this, _run, run).call(this, 'sign-in', async ()=>{
|
|
75
|
+
const attempt = await this.deps.store.passkeys.claimAttempt({
|
|
76
|
+
tokenHash: hashBearer(args.ceremonyId),
|
|
77
|
+
purpose: 'sign-in',
|
|
78
|
+
binding: {
|
|
79
|
+
actor_user_id: null,
|
|
80
|
+
actor_login_id: null,
|
|
81
|
+
actor_generation: null
|
|
82
|
+
},
|
|
83
|
+
now: this.deps.now()
|
|
84
|
+
});
|
|
85
|
+
if (!attempt) throw expired();
|
|
86
|
+
const response = parseResponse(args.response, 'webauthn.get');
|
|
87
|
+
const key = active(await this.deps.store.passkeys.findCredentialByCredentialId(response.id));
|
|
88
|
+
const user = await this.deps.store.adminUsers.getByIdForSignIn(key.admin_user_id);
|
|
89
|
+
if (!user?.is_enabled) throw invalidProof();
|
|
90
|
+
const verified = await _class_private_method_get(this, _verifyAuthentication, verifyAuthentication).call(this, response, key, attempt);
|
|
91
|
+
return this.deps.signIn(user, args, async (store)=>{
|
|
92
|
+
_class_private_method_get(this, _unexpired, unexpired).call(this, attempt);
|
|
93
|
+
await _class_private_method_get(this, _applyVerifiedKey, applyVerifiedKey).call(this, store, key, verified);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
passwordProof(args) {
|
|
98
|
+
return _class_private_method_get(this, _run, run).call(this, 'password-proof', async ()=>{
|
|
99
|
+
const observed = await this.deps.authenticate(args);
|
|
100
|
+
const parsed = passwordSignInSchema.safeParse({
|
|
101
|
+
email: observed.user.email,
|
|
102
|
+
password: args.password
|
|
103
|
+
});
|
|
104
|
+
if (!parsed.success || !await verifyPassword(parsed.data.password, observed.user.password_hash)) throw invalidProof();
|
|
105
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store)=>{
|
|
106
|
+
const current = await this.deps.authenticate(args, store);
|
|
107
|
+
if (current.user.password_hash !== observed.user.password_hash || current.user.session_version !== observed.user.session_version) throw invalidProof();
|
|
108
|
+
const action = await _class_private_method_get(this, _authorizeAction, authorizeAction).call(this, args.action, current, store);
|
|
109
|
+
return _class_private_method_get(this, _grant, native_passkey_provider_grant).call(this, store, current, action, null);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
proofOptions(args) {
|
|
114
|
+
return _class_private_method_get(this, _run, run).call(this, 'proof-options', async ()=>{
|
|
115
|
+
const observed = await this.deps.authenticate(args);
|
|
116
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store)=>{
|
|
117
|
+
const current = await this.deps.authenticate(args, store);
|
|
118
|
+
const action = await _class_private_method_get(this, _authorizeAction, authorizeAction).call(this, args.action, current, store);
|
|
119
|
+
await _class_private_method_get(this, _capacity, capacity).call(this, store, current.sessionId);
|
|
120
|
+
const { raw, row } = _class_private_method_get(this, _newAttempt, newAttempt).call(this, 'fresh-authentication', current, action);
|
|
121
|
+
const options = await _class_private_method_get(this, _authenticationOptions, authenticationOptions).call(this, row.challenge);
|
|
122
|
+
await store.passkeys.createAttempt(row);
|
|
123
|
+
return {
|
|
124
|
+
ceremonyId: raw,
|
|
125
|
+
expiresAt: row.expires_at.toISOString(),
|
|
126
|
+
options
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
verifyProof(args) {
|
|
132
|
+
return _class_private_method_get(this, _run, run).call(this, 'proof', async ()=>{
|
|
133
|
+
const observed = await this.deps.authenticate(args);
|
|
134
|
+
const attempt = await _class_private_method_get(this, _claimAuthenticated, claimAuthenticated).call(this, args.ceremonyId, 'fresh-authentication', observed);
|
|
135
|
+
const response = parseResponse(args.response, 'webauthn.get');
|
|
136
|
+
const key = active(await this.deps.store.passkeys.findCredentialByCredentialId(response.id));
|
|
137
|
+
if (key.admin_user_id !== observed.user.id) throw invalidProof();
|
|
138
|
+
const verified = await _class_private_method_get(this, _verifyAuthentication, verifyAuthentication).call(this, response, key, attempt);
|
|
139
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store)=>{
|
|
140
|
+
const current = await this.deps.authenticate(args, store);
|
|
141
|
+
await _class_private_method_get(this, _recheckAuthority, recheckAuthority).call(this, store, attempt, current);
|
|
142
|
+
const action = await _class_private_method_get(this, _authorizeAction, authorizeAction).call(this, actionOf(attempt), current, store);
|
|
143
|
+
await _class_private_method_get(this, _applyVerifiedKey, applyVerifiedKey).call(this, store, key, verified);
|
|
144
|
+
return _class_private_method_get(this, _grant, native_passkey_provider_grant).call(this, store, current, action, key.id);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
enrollmentOptions(args) {
|
|
149
|
+
return _class_private_method_get(this, _run, run).call(this, 'enrollment-options', async ()=>{
|
|
150
|
+
const observed = await this.deps.authenticate(args);
|
|
151
|
+
const grant = await _class_private_method_get(this, _claimAuthenticated, claimAuthenticated).call(this, args.grantId, 'grant', observed, 'enroll');
|
|
152
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store)=>{
|
|
153
|
+
const current = await this.deps.authenticate(args, store);
|
|
154
|
+
await _class_private_method_get(this, _recheckAuthority, recheckAuthority).call(this, store, grant, current);
|
|
155
|
+
await _class_private_method_get(this, _authorizeAction, authorizeAction).call(this, actionOf(grant), current, store);
|
|
156
|
+
await _class_private_method_get(this, _capacity, capacity).call(this, store, current.sessionId);
|
|
157
|
+
const existing = await store.passkeys.listCredentials(current.user.id);
|
|
158
|
+
if (existing.length >= MAX_CREDENTIALS) throw limit();
|
|
159
|
+
const handle = await store.passkeys.ensureUserHandle(current.user.id, opaque());
|
|
160
|
+
const { raw, row } = _class_private_method_get(this, _newAttempt, newAttempt).call(this, 'enrollment', current, {
|
|
161
|
+
action: 'enroll',
|
|
162
|
+
target_user_id: current.user.id,
|
|
163
|
+
credential_record_id: null
|
|
164
|
+
});
|
|
165
|
+
row.expires_at = grant.expires_at;
|
|
166
|
+
row.proof_credential_id = grant.proof_credential_id;
|
|
167
|
+
const generated = await generateRegistrationOptions({
|
|
168
|
+
rpID: _class_private_field_get(this, _trust).rpId,
|
|
169
|
+
rpName: _class_private_field_get(this, _trust).rpName,
|
|
170
|
+
userName: current.user.email,
|
|
171
|
+
userID: Buffer.from(handle, 'base64url'),
|
|
172
|
+
userDisplayName: current.user.given_name || current.user.email,
|
|
173
|
+
challenge: Buffer.from(row.challenge, 'base64url'),
|
|
174
|
+
timeout: Math.max(1, grant.expires_at.getTime() - this.deps.now().getTime()),
|
|
175
|
+
attestationType: 'none',
|
|
176
|
+
authenticatorSelection: {
|
|
177
|
+
residentKey: 'required',
|
|
178
|
+
requireResidentKey: true,
|
|
179
|
+
userVerification: 'required'
|
|
180
|
+
},
|
|
181
|
+
excludeCredentials: existing.map((key)=>({
|
|
182
|
+
id: key.credential_id
|
|
183
|
+
})),
|
|
184
|
+
extensions: {
|
|
185
|
+
credProps: true
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
const options = {
|
|
189
|
+
...generated,
|
|
190
|
+
excludeCredentials: existing.map((key)=>({
|
|
191
|
+
id: key.credential_id,
|
|
192
|
+
type: 'public-key'
|
|
193
|
+
})),
|
|
194
|
+
rp: {
|
|
195
|
+
id: _class_private_field_get(this, _trust).rpId,
|
|
196
|
+
name: _class_private_field_get(this, _trust).rpName
|
|
197
|
+
},
|
|
198
|
+
attestation: 'none',
|
|
199
|
+
authenticatorSelection: {
|
|
200
|
+
residentKey: 'required',
|
|
201
|
+
requireResidentKey: true,
|
|
202
|
+
userVerification: 'required'
|
|
203
|
+
},
|
|
204
|
+
extensions: {
|
|
205
|
+
credProps: true
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
_class_private_method_get(this, _unexpired, unexpired).call(this, row);
|
|
209
|
+
await store.passkeys.createAttempt(row);
|
|
210
|
+
return {
|
|
211
|
+
ceremonyId: raw,
|
|
212
|
+
expiresAt: row.expires_at.toISOString(),
|
|
213
|
+
options
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
verifyEnrollment(args) {
|
|
219
|
+
return _class_private_method_get(this, _run, run).call(this, 'enrollment', async ()=>{
|
|
220
|
+
const observed = await this.deps.authenticate(args);
|
|
221
|
+
const attempt = await _class_private_method_get(this, _claimAuthenticated, claimAuthenticated).call(this, args.ceremonyId, 'enrollment', observed, 'enroll');
|
|
222
|
+
const name = displayName(args.name);
|
|
223
|
+
const response = parseResponse(args.response, 'webauthn.create');
|
|
224
|
+
if (response.clientExtensionResults.credProps?.rk === false) throw invalidProof();
|
|
225
|
+
const verified = await verifyRegistrationResponse({
|
|
226
|
+
response,
|
|
227
|
+
expectedChallenge: attempt.challenge,
|
|
228
|
+
expectedOrigin: [
|
|
229
|
+
..._class_private_field_get(this, _trust).origins
|
|
230
|
+
],
|
|
231
|
+
expectedRPID: _class_private_field_get(this, _trust).rpId,
|
|
232
|
+
expectedType: 'webauthn.create',
|
|
233
|
+
requireUserVerification: true,
|
|
234
|
+
requireUserPresence: true
|
|
235
|
+
}).catch(()=>{
|
|
236
|
+
throw invalidProof();
|
|
237
|
+
});
|
|
238
|
+
if (!verified.verified || !verified.registrationInfo.userVerified || verified.registrationInfo.credential.id !== response.id) throw invalidProof();
|
|
239
|
+
const info = verified.registrationInfo;
|
|
240
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store)=>{
|
|
241
|
+
const current = await this.deps.authenticate(args, store);
|
|
242
|
+
await _class_private_method_get(this, _recheckAuthority, recheckAuthority).call(this, store, attempt, current);
|
|
243
|
+
await _class_private_method_get(this, _authorizeAction, authorizeAction).call(this, actionOf(attempt), current, store);
|
|
244
|
+
if ((await store.passkeys.listCredentials(current.user.id)).length >= MAX_CREDENTIALS) throw limit();
|
|
245
|
+
if (await store.passkeys.findCredentialByCredentialId(info.credential.id)) throw invalidProof();
|
|
246
|
+
const row = {
|
|
247
|
+
id: v7(),
|
|
248
|
+
admin_user_id: current.user.id,
|
|
249
|
+
credential_id: info.credential.id,
|
|
250
|
+
public_key: Buffer.from(info.credential.publicKey).toString('base64url'),
|
|
251
|
+
counter: info.credential.counter,
|
|
252
|
+
transports: info.credential.transports ?? [],
|
|
253
|
+
backup_eligible: 'multiDevice' === info.credentialDeviceType,
|
|
254
|
+
backed_up: info.credentialBackedUp,
|
|
255
|
+
name,
|
|
256
|
+
created_at: this.deps.now(),
|
|
257
|
+
last_used_at: null,
|
|
258
|
+
revoked_at: null
|
|
259
|
+
};
|
|
260
|
+
await store.passkeys.createCredential(row);
|
|
261
|
+
return summary(row);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
listCredentials(args) {
|
|
266
|
+
return _class_private_method_get(this, _run, run).call(this, 'list', async ()=>{
|
|
267
|
+
const observed = await this.deps.authenticate(args);
|
|
268
|
+
const targetId = args.targetUserId ?? observed.user.id;
|
|
269
|
+
if (!validate(targetId)) throw forbidden();
|
|
270
|
+
return this.deps.store.withSessionLocks([
|
|
271
|
+
observed.user.id,
|
|
272
|
+
targetId
|
|
273
|
+
], async (store)=>{
|
|
274
|
+
const current = await this.deps.authenticate(args, store);
|
|
275
|
+
await _class_private_method_get(this, _authorizeTarget, authorizeTarget).call(this, targetId, current, store);
|
|
276
|
+
return (await store.passkeys.listCredentials(targetId)).map(summary);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
renameCredential(args) {
|
|
281
|
+
return _class_private_method_get(this, _run, run).call(this, 'rename', async ()=>{
|
|
282
|
+
const observed = await this.deps.authenticate(args);
|
|
283
|
+
const name = displayName(args.name);
|
|
284
|
+
if (!validate(args.credentialRecordId)) throw forbidden();
|
|
285
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store)=>{
|
|
286
|
+
const current = await this.deps.authenticate(args, store);
|
|
287
|
+
const key = active(await store.passkeys.findCredential(args.credentialRecordId));
|
|
288
|
+
if (key.admin_user_id !== current.user.id) throw forbidden();
|
|
289
|
+
await store.passkeys.updateCredential(key.id, {
|
|
290
|
+
name
|
|
291
|
+
});
|
|
292
|
+
return summary({
|
|
293
|
+
...key,
|
|
294
|
+
name
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
removeCredential(args) {
|
|
300
|
+
return _class_private_method_get(this, _run, run).call(this, 'remove', async ()=>{
|
|
301
|
+
const observed = await this.deps.authenticate(args);
|
|
302
|
+
const grant = await _class_private_method_get(this, _claimAuthenticated, claimAuthenticated).call(this, args.grantId, 'grant', observed, 'remove');
|
|
303
|
+
const action = actionOf(grant);
|
|
304
|
+
if ('remove' !== action.purpose) throw expired();
|
|
305
|
+
const audit = {
|
|
306
|
+
actorId: observed.user.id,
|
|
307
|
+
targetId: action.targetUserId,
|
|
308
|
+
credentialId: action.credentialRecordId
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
return await this.deps.store.withSessionLocks([
|
|
312
|
+
observed.user.id,
|
|
313
|
+
action.targetUserId
|
|
314
|
+
], async (store)=>{
|
|
315
|
+
const current = await this.deps.authenticate(args, store);
|
|
316
|
+
await _class_private_method_get(this, _recheckAuthority, recheckAuthority).call(this, store, grant, current);
|
|
317
|
+
await _class_private_method_get(this, _authorizeAction, authorizeAction).call(this, action, current, store);
|
|
318
|
+
await store.passkeys.revokeCredential(action.credentialRecordId, this.deps.now());
|
|
319
|
+
await store.passkeys.invalidateAccountLogins(action.targetUserId, this.deps.now());
|
|
320
|
+
if (current.user.id !== action.targetUserId) await store.passkeys.appendRemovalAudit({
|
|
321
|
+
...audit,
|
|
322
|
+
outcome: 'success'
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
signedOut: current.user.id === action.targetUserId
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (observed.user.id !== action.targetUserId) await this.deps.store.passkeys.appendRemovalAudit({
|
|
330
|
+
...audit,
|
|
331
|
+
outcome: error instanceof AuthError && error.code === AuthErrorCodes.FORBIDDEN ? 'denied' : 'failed'
|
|
332
|
+
}).catch(()=>{});
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
constructor(trust, deps){
|
|
338
|
+
_class_private_method_init(this, _run);
|
|
339
|
+
_class_private_method_init(this, _authenticationOptions);
|
|
340
|
+
_class_private_method_init(this, _newAttempt);
|
|
341
|
+
_class_private_method_init(this, _claimAuthenticated);
|
|
342
|
+
_class_private_method_init(this, _unexpired);
|
|
343
|
+
_class_private_method_init(this, _recheckAuthority);
|
|
344
|
+
_class_private_method_init(this, _authorizeTarget);
|
|
345
|
+
_class_private_method_init(this, _authorizeAction);
|
|
346
|
+
_class_private_method_init(this, _capacity);
|
|
347
|
+
_class_private_method_init(this, _grant);
|
|
348
|
+
_class_private_method_init(this, _verifyAuthentication);
|
|
349
|
+
_class_private_method_init(this, _applyVerifiedKey);
|
|
350
|
+
_class_private_field_init(this, _trust, {
|
|
351
|
+
writable: true,
|
|
352
|
+
value: void 0
|
|
353
|
+
});
|
|
354
|
+
this.deps = deps;
|
|
355
|
+
const resolved = resolvePasskeyConfig(trust, {
|
|
356
|
+
capabilities: {
|
|
357
|
+
passkeys: true,
|
|
358
|
+
passwordChange: true,
|
|
359
|
+
magicLink: false,
|
|
360
|
+
sso: false
|
|
361
|
+
},
|
|
362
|
+
passkeys: this
|
|
363
|
+
});
|
|
364
|
+
if (!resolved) throw new Error('Passkey trust configuration is required');
|
|
365
|
+
_class_private_field_set(this, _trust, resolved);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function run(purpose, work) {
|
|
369
|
+
this.deps.emit({
|
|
370
|
+
type: 'passkey_attempted',
|
|
371
|
+
purpose
|
|
372
|
+
});
|
|
373
|
+
try {
|
|
374
|
+
const result = await work();
|
|
375
|
+
this.deps.emit({
|
|
376
|
+
type: 'passkey_completed',
|
|
377
|
+
purpose
|
|
378
|
+
});
|
|
379
|
+
return result;
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const reason = error instanceof AuthError ? error.code === AuthErrorCodes.FORBIDDEN ? 'forbidden' : error.code === AuthErrorCodes.PASSKEY_LIMIT ? 'limit' : error.code === AuthErrorCodes.INVALID_CREDENTIALS ? 'invalid-proof' : 'stale-authority' : 'unavailable';
|
|
382
|
+
this.deps.emit({
|
|
383
|
+
type: 'passkey_failed',
|
|
384
|
+
purpose,
|
|
385
|
+
reason
|
|
386
|
+
});
|
|
387
|
+
if (error instanceof AuthError) throw new AuthError(error.code, {
|
|
388
|
+
message: error.message
|
|
389
|
+
});
|
|
390
|
+
throw new AuthError(AuthErrorCodes.PASSKEY_UNAVAILABLE, {
|
|
391
|
+
message: 'Passkey service unavailable'
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function authenticationOptions(challenge) {
|
|
396
|
+
const generated = await generateAuthenticationOptions({
|
|
397
|
+
rpID: _class_private_field_get(this, _trust).rpId,
|
|
398
|
+
challenge: Buffer.from(challenge, 'base64url'),
|
|
399
|
+
timeout: LIFETIME_MS,
|
|
400
|
+
userVerification: 'required'
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
challenge: generated.challenge,
|
|
404
|
+
timeout: generated.timeout,
|
|
405
|
+
rpId: _class_private_field_get(this, _trust).rpId,
|
|
406
|
+
userVerification: 'required'
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function newAttempt(purpose, actor, action) {
|
|
410
|
+
const raw = opaque();
|
|
411
|
+
const now = this.deps.now();
|
|
412
|
+
const row = {
|
|
413
|
+
token_hash: hashBearer(raw),
|
|
414
|
+
purpose,
|
|
415
|
+
challenge: 'grant' === purpose ? null : opaque(),
|
|
416
|
+
actor_user_id: actor?.user.id ?? null,
|
|
417
|
+
actor_login_id: actor?.sessionId ?? null,
|
|
418
|
+
actor_generation: actor?.user.session_version ?? null,
|
|
419
|
+
action: action?.action ?? null,
|
|
420
|
+
target_user_id: action?.target_user_id ?? null,
|
|
421
|
+
credential_record_id: action?.credential_record_id ?? null,
|
|
422
|
+
proof_credential_id: null,
|
|
423
|
+
created_at: now,
|
|
424
|
+
expires_at: new Date(now.getTime() + LIFETIME_MS)
|
|
425
|
+
};
|
|
426
|
+
return {
|
|
427
|
+
raw,
|
|
428
|
+
row
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function claimAuthenticated(raw, purpose, actor, requiredAction) {
|
|
432
|
+
const tokenHash = hashBearer(raw);
|
|
433
|
+
const observed = await this.deps.store.passkeys.findAttempt(tokenHash);
|
|
434
|
+
if (!observed?.action || observed.purpose !== purpose) throw expired();
|
|
435
|
+
const row = await this.deps.store.passkeys.claimAttempt({
|
|
436
|
+
tokenHash,
|
|
437
|
+
purpose,
|
|
438
|
+
action: requiredAction ?? observed.action,
|
|
439
|
+
binding: {
|
|
440
|
+
actor_user_id: actor.user.id,
|
|
441
|
+
actor_login_id: actor.sessionId,
|
|
442
|
+
actor_generation: actor.user.session_version
|
|
443
|
+
},
|
|
444
|
+
now: this.deps.now()
|
|
445
|
+
});
|
|
446
|
+
if (!row) throw expired();
|
|
447
|
+
return row;
|
|
448
|
+
}
|
|
449
|
+
function unexpired(row) {
|
|
450
|
+
if (row.expires_at.getTime() <= this.deps.now().getTime()) throw expired();
|
|
451
|
+
}
|
|
452
|
+
async function recheckAuthority(store, row, actor) {
|
|
453
|
+
_class_private_method_get(this, _unexpired, unexpired).call(this, row);
|
|
454
|
+
if (row.actor_user_id !== actor.user.id || row.actor_login_id !== actor.sessionId || row.actor_generation !== actor.user.session_version) throw ERR_SESSION_CHANGED({
|
|
455
|
+
message: 'Fresh proof authority changed; start again'
|
|
456
|
+
});
|
|
457
|
+
if (row.proof_credential_id) {
|
|
458
|
+
const key = active(await store.passkeys.findCredential(row.proof_credential_id));
|
|
459
|
+
if (key.admin_user_id !== actor.user.id) throw invalidProof();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async function authorizeTarget(targetId, actor, store) {
|
|
463
|
+
if (!validate(targetId)) throw forbidden();
|
|
464
|
+
const target = await store.adminUsers.getById(targetId);
|
|
465
|
+
if (!target) throw forbidden();
|
|
466
|
+
if (targetId !== actor.user.id) {
|
|
467
|
+
actor.actor.assertAbility(ADMIN_USERS_ABILITIES.managePasskeys);
|
|
468
|
+
if (target.is_super_admin && !actor.actor.isSuperAdmin) throw forbidden();
|
|
469
|
+
}
|
|
470
|
+
return target;
|
|
471
|
+
}
|
|
472
|
+
async function authorizeAction(action, actor, store) {
|
|
473
|
+
if (action?.purpose === 'enroll') return {
|
|
474
|
+
action: 'enroll',
|
|
475
|
+
target_user_id: actor.user.id,
|
|
476
|
+
credential_record_id: null
|
|
477
|
+
};
|
|
478
|
+
if (action?.purpose !== 'remove' || !validate(action.credentialRecordId)) throw forbidden();
|
|
479
|
+
await _class_private_method_get(this, _authorizeTarget, authorizeTarget).call(this, action.targetUserId, actor, store);
|
|
480
|
+
const key = active(await store.passkeys.findCredential(action.credentialRecordId));
|
|
481
|
+
if (key.admin_user_id !== action.targetUserId) throw forbidden();
|
|
482
|
+
return {
|
|
483
|
+
action: 'remove',
|
|
484
|
+
target_user_id: action.targetUserId,
|
|
485
|
+
credential_record_id: key.id
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
async function capacity(store, sessionId) {
|
|
489
|
+
if (await store.passkeys.countPendingForLogin(sessionId, this.deps.now()) >= MAX_PENDING) throw limit();
|
|
490
|
+
}
|
|
491
|
+
async function native_passkey_provider_grant(store, actor, action, proofCredential) {
|
|
492
|
+
await _class_private_method_get(this, _capacity, capacity).call(this, store, actor.sessionId);
|
|
493
|
+
const { raw, row } = _class_private_method_get(this, _newAttempt, newAttempt).call(this, 'grant', actor, action);
|
|
494
|
+
row.proof_credential_id = proofCredential;
|
|
495
|
+
await store.passkeys.createAttempt(row);
|
|
496
|
+
return {
|
|
497
|
+
grantId: raw,
|
|
498
|
+
expiresAt: row.expires_at.toISOString()
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
async function verifyAuthentication(response, key, attempt) {
|
|
502
|
+
const handle = await this.deps.store.passkeys.getUserHandle(key.admin_user_id);
|
|
503
|
+
if (!handle || response.response.userHandle !== handle) throw invalidProof();
|
|
504
|
+
const result = await verifyAuthenticationResponse({
|
|
505
|
+
response,
|
|
506
|
+
expectedChallenge: attempt.challenge,
|
|
507
|
+
expectedOrigin: [
|
|
508
|
+
..._class_private_field_get(this, _trust).origins
|
|
509
|
+
],
|
|
510
|
+
expectedRPID: _class_private_field_get(this, _trust).rpId,
|
|
511
|
+
expectedType: 'webauthn.get',
|
|
512
|
+
requireUserVerification: true,
|
|
513
|
+
credential: {
|
|
514
|
+
id: key.credential_id,
|
|
515
|
+
publicKey: Buffer.from(key.public_key, 'base64url'),
|
|
516
|
+
counter: key.counter
|
|
517
|
+
}
|
|
518
|
+
}).catch(()=>{
|
|
519
|
+
throw invalidProof();
|
|
520
|
+
});
|
|
521
|
+
if (!result.verified || !result.authenticationInfo.userVerified || 'multiDevice' === result.authenticationInfo.credentialDeviceType !== key.backup_eligible) throw invalidProof();
|
|
522
|
+
return result.authenticationInfo;
|
|
523
|
+
}
|
|
524
|
+
async function applyVerifiedKey(store, snapshot, verified) {
|
|
525
|
+
const current = active(await store.passkeys.findCredential(snapshot.id));
|
|
526
|
+
for (const field of [
|
|
527
|
+
'admin_user_id',
|
|
528
|
+
'credential_id',
|
|
529
|
+
'public_key',
|
|
530
|
+
'counter',
|
|
531
|
+
'backup_eligible',
|
|
532
|
+
'backed_up'
|
|
533
|
+
])if (current[field] !== snapshot[field]) throw invalidProof();
|
|
534
|
+
await store.passkeys.updateCredential(current.id, {
|
|
535
|
+
counter: verified.newCounter,
|
|
536
|
+
backed_up: verified.credentialBackedUp,
|
|
537
|
+
last_used_at: this.deps.now()
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function opaque() {
|
|
541
|
+
return randomBytes(32).toString('base64url');
|
|
542
|
+
}
|
|
543
|
+
function hashBearer(raw) {
|
|
544
|
+
try {
|
|
545
|
+
assertPasskeyBytes(raw, 32, 32);
|
|
546
|
+
} catch {
|
|
547
|
+
throw expired();
|
|
548
|
+
}
|
|
549
|
+
return createHash('sha256').update(raw).digest('hex');
|
|
550
|
+
}
|
|
551
|
+
function expired() {
|
|
552
|
+
return ERR_INVALID_TOKEN({
|
|
553
|
+
message: 'Passkey attempt expired or already used; start again'
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
function invalidProof() {
|
|
557
|
+
return ERR_INVALID_CREDENTIALS({
|
|
558
|
+
message: 'Passkey verification failed; start again'
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
function forbidden() {
|
|
562
|
+
return ERR_FORBIDDEN({
|
|
563
|
+
message: 'Passkey management is not permitted'
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
function limit() {
|
|
567
|
+
return new AuthError(AuthErrorCodes.PASSKEY_LIMIT, {
|
|
568
|
+
message: 'Passkey credential or pending-operation limit reached'
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function active(row) {
|
|
572
|
+
if (!row || null !== row.revoked_at || null === row.admin_user_id) throw invalidProof();
|
|
573
|
+
return row;
|
|
574
|
+
}
|
|
575
|
+
function displayName(value) {
|
|
576
|
+
if ('string' != typeof value || !value.trim() || value.length > 80) throw forbidden();
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
function summary(row) {
|
|
580
|
+
return {
|
|
581
|
+
id: row.id,
|
|
582
|
+
name: row.name,
|
|
583
|
+
createdAt: row.created_at.toISOString(),
|
|
584
|
+
lastUsedAt: row.last_used_at?.toISOString() ?? null
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function actionOf(row) {
|
|
588
|
+
if ('enroll' === row.action) return {
|
|
589
|
+
purpose: 'enroll'
|
|
590
|
+
};
|
|
591
|
+
if ('remove' === row.action && row.target_user_id && row.credential_record_id) return {
|
|
592
|
+
purpose: 'remove',
|
|
593
|
+
targetUserId: row.target_user_id,
|
|
594
|
+
credentialRecordId: row.credential_record_id
|
|
595
|
+
};
|
|
596
|
+
throw expired();
|
|
597
|
+
}
|
|
598
|
+
function parseResponse(value, expectedType) {
|
|
599
|
+
try {
|
|
600
|
+
if (!value || 'object' != typeof value || Array.isArray(value) || Buffer.byteLength(JSON.stringify(value)) > 65536) throw invalidProof();
|
|
601
|
+
const response = value;
|
|
602
|
+
if ('public-key' !== response.type || response.id !== response.rawId || !response.response || 'object' != typeof response.response || !response.clientExtensionResults || 'object' != typeof response.clientExtensionResults) throw invalidProof();
|
|
603
|
+
assertPasskeyBytes(response.id, 1024);
|
|
604
|
+
assertPasskeyBytes(response.response.clientDataJSON, 16384);
|
|
605
|
+
const client = JSON.parse(Buffer.from(response.response.clientDataJSON, 'base64url').toString('utf8'));
|
|
606
|
+
if (!client || client.type !== expectedType || void 0 !== client.crossOrigin && false !== client.crossOrigin) throw invalidProof();
|
|
607
|
+
if ('webauthn.get' === expectedType) {
|
|
608
|
+
const auth = response;
|
|
609
|
+
assertPasskeyBytes(auth.response.authenticatorData, 16384);
|
|
610
|
+
assertPasskeyBytes(auth.response.signature, 16384);
|
|
611
|
+
assertPasskeyBytes(auth.response.userHandle ?? '', 32, 32);
|
|
612
|
+
} else assertPasskeyBytes(response.response.attestationObject, 49152);
|
|
613
|
+
return response;
|
|
614
|
+
} catch {
|
|
615
|
+
throw invalidProof();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
export { NativePasskeyProvider };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { PasskeyRequestLimiter } from '@byline/auth';
|
|
9
|
+
import type { RecurringTaskDefinition } from '@byline/core';
|
|
10
|
+
import { type SignInRateLimitStore } from './sign-in-rate-limiter.js';
|
|
11
|
+
/** Separate WebAuthn capacity and key domain; counter storage and cleanup are shared. */
|
|
12
|
+
export declare function createPasskeyRequestLimiter(store: SignInRateLimitStore, secret: string | Uint8Array, now?: () => number): PasskeyRequestLimiter & {
|
|
13
|
+
cleanupTask: RecurringTaskDefinition;
|
|
14
|
+
};
|