@nacre.work/api 0.18.0 → 0.19.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/login.d.ts +51 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +50 -14
- package/dist/login.js.map +1 -1
- package/dist/main.js +41 -15
- package/dist/main.js.map +1 -1
- package/dist/second-factor.d.ts +116 -3
- package/dist/second-factor.d.ts.map +1 -1
- package/dist/second-factor.js +221 -9
- package/dist/second-factor.js.map +1 -1
- package/dist/server.d.ts +9 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +232 -14
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -564,7 +564,15 @@ function handledTooBusy(res, error, instance, requestId) {
|
|
|
564
564
|
return true;
|
|
565
565
|
}
|
|
566
566
|
async function handleAuth(req, res, instance, requestId, options) {
|
|
567
|
-
|
|
567
|
+
// Every route here produces a credential and therefore takes a body, with
|
|
568
|
+
// exactly one exception: `/v1/auth/methods` is a **read**, and the contract
|
|
569
|
+
// has said `get` since it was written. A blanket `!== 'POST'` refused it, so
|
|
570
|
+
// the endpoint whose whole job is telling a sign-in screen what exists
|
|
571
|
+
// answered 404 — and the console, reading `password_reset` off a problem
|
|
572
|
+
// document, hid the recovery link on every deployment including the ones
|
|
573
|
+
// with a relay configured. The feature did the opposite of its purpose.
|
|
574
|
+
const reads = instance === '/v1/auth/methods';
|
|
575
|
+
if (options.login === undefined || req.method !== (reads ? 'GET' : 'POST')) {
|
|
568
576
|
const problem = notFound(instance, requestId);
|
|
569
577
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
570
578
|
return;
|
|
@@ -701,7 +709,14 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
701
709
|
* would learn by pressing the link anyway. It says nothing about any address.
|
|
702
710
|
*/
|
|
703
711
|
if (instance === '/v1/auth/methods') {
|
|
704
|
-
send(res, 200, {
|
|
712
|
+
send(res, 200, {
|
|
713
|
+
password_reset: options.recovery !== undefined,
|
|
714
|
+
// Which second factors this installation can *challenge* with. A
|
|
715
|
+
// sign-in screen that offered "use your security key" where the
|
|
716
|
+
// deployment has no relying party would be the same defect the
|
|
717
|
+
// recovery link was: a control the server refuses.
|
|
718
|
+
second_factor_kinds: options.secondFactors?.kinds ?? [],
|
|
719
|
+
}, requestId);
|
|
705
720
|
return;
|
|
706
721
|
}
|
|
707
722
|
/*
|
|
@@ -784,10 +799,41 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
784
799
|
* survives a Redis restart; this one is the bound that costs an attacker
|
|
785
800
|
* their source.
|
|
786
801
|
*/
|
|
802
|
+
/*
|
|
803
|
+
* The options a browser needs before it can produce an assertion.
|
|
804
|
+
*
|
|
805
|
+
* It takes the sign-in challenge and nothing else, so this is not a route
|
|
806
|
+
* that will tell a stranger which authenticators an address holds: the
|
|
807
|
+
* challenge is a JWT this server signed one password ago. Not rate limited
|
|
808
|
+
* separately, because reaching it costs a correct password.
|
|
809
|
+
*/
|
|
810
|
+
if (instance === '/v1/auth/second-factor/webauthn') {
|
|
811
|
+
const challenge = body?.challenge;
|
|
812
|
+
if (typeof challenge !== 'string') {
|
|
813
|
+
const problem = badRequest(instance, requestId, "'challenge' is required.");
|
|
814
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const begun = await options.login.beginSecondFactorWebAuthn(challenge);
|
|
818
|
+
if (begun === undefined) {
|
|
819
|
+
// One refusal for a challenge that is not ours, one that has expired, and
|
|
820
|
+
// a person holding no key — the same rule the rest of this path follows.
|
|
821
|
+
refuse();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
send(res, 200, assertionOptionsJson(begun), requestId);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
787
827
|
if (instance === '/v1/auth/second-factor') {
|
|
788
|
-
const { challenge, code } = (body ?? {});
|
|
789
|
-
if (typeof challenge !== 'string'
|
|
790
|
-
const problem = badRequest(instance, requestId, "'challenge'
|
|
828
|
+
const { challenge, code, assertion } = (body ?? {});
|
|
829
|
+
if (typeof challenge !== 'string') {
|
|
830
|
+
const problem = badRequest(instance, requestId, "'challenge' is required.");
|
|
831
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const proof = readProof(code, assertion);
|
|
835
|
+
if (proof === undefined) {
|
|
836
|
+
const problem = badRequest(instance, requestId, "Either 'code' or 'assertion' is required.");
|
|
791
837
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
792
838
|
return;
|
|
793
839
|
}
|
|
@@ -809,7 +855,7 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
809
855
|
}
|
|
810
856
|
}
|
|
811
857
|
}
|
|
812
|
-
const tokens = await options.login.completeSecondFactor(challenge,
|
|
858
|
+
const tokens = await options.login.completeSecondFactor(challenge, proof);
|
|
813
859
|
if (tokens === undefined) {
|
|
814
860
|
// One refusal for an expired challenge, a forged one, a wrong code and a
|
|
815
861
|
// disabled account alike. Which of the four it was is nothing a client
|
|
@@ -825,8 +871,10 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
825
871
|
result: 'allow',
|
|
826
872
|
target: { user_id: tokens.userId },
|
|
827
873
|
// The journal says which door, because "signed in with a second factor"
|
|
828
|
-
// and "signed in with a password" are different facts to an investigator
|
|
829
|
-
|
|
874
|
+
// and "signed in with a password" are different facts to an investigator
|
|
875
|
+
// — and so are the two kinds of second factor, since only one of them is
|
|
876
|
+
// proof against a page that looked like this one.
|
|
877
|
+
detail: { second_factor: true, second_factor_kind: proof.kind },
|
|
830
878
|
requestId,
|
|
831
879
|
});
|
|
832
880
|
send(res, 200, tokenJson(tokens), requestId);
|
|
@@ -857,6 +905,71 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
857
905
|
const problem = notFound(instance, requestId);
|
|
858
906
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
859
907
|
}
|
|
908
|
+
/**
|
|
909
|
+
* A WebAuthn assertion out of a request body, or nothing.
|
|
910
|
+
*
|
|
911
|
+
* One parser, because two routes take one: the second half of a sign-in and
|
|
912
|
+
* taking a factor off an account. Two would be two ideas of which fields are
|
|
913
|
+
* required and which encoding they arrive in, and the half that got it wrong
|
|
914
|
+
* would refuse every authenticator with a message about neither.
|
|
915
|
+
*
|
|
916
|
+
* base64url throughout, which is what a browser reports and what
|
|
917
|
+
* `webauthn_challenges.challenge` stores — so the comparison downstream is a
|
|
918
|
+
* string equality and never an encoding round trip.
|
|
919
|
+
*/
|
|
920
|
+
function readAssertion(value) {
|
|
921
|
+
if (typeof value !== 'object' || value === null)
|
|
922
|
+
return undefined;
|
|
923
|
+
const it = value;
|
|
924
|
+
const fields = ['credential_id', 'authenticator_data', 'client_data_json', 'signature', 'challenge'];
|
|
925
|
+
if (fields.some((f) => typeof it[f] !== 'string' || it[f] === ''))
|
|
926
|
+
return undefined;
|
|
927
|
+
const bytes = (name) => new Uint8Array(Buffer.from(it[name], 'base64url'));
|
|
928
|
+
return {
|
|
929
|
+
credentialId: it.credential_id,
|
|
930
|
+
authenticatorData: bytes('authenticator_data'),
|
|
931
|
+
clientDataJSON: bytes('client_data_json'),
|
|
932
|
+
signature: bytes('signature'),
|
|
933
|
+
challenge: it.challenge,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Which of the two proofs a body carries.
|
|
938
|
+
*
|
|
939
|
+
* Both present is a refusal rather than a precedence. A caller sending each is
|
|
940
|
+
* a caller with two ideas of how it is signing in, and resolving that by
|
|
941
|
+
* preferring one leaves the other apparently offered and ignored — which is
|
|
942
|
+
* the argument `loadConfig` makes about a secret and a key ref together.
|
|
943
|
+
*/
|
|
944
|
+
function readProof(code, assertion) {
|
|
945
|
+
const hasCode = typeof code === 'string' && code !== '';
|
|
946
|
+
if (hasCode && assertion !== undefined)
|
|
947
|
+
return undefined;
|
|
948
|
+
if (hasCode)
|
|
949
|
+
return { kind: 'code', code: code };
|
|
950
|
+
const response = readAssertion(assertion);
|
|
951
|
+
return response === undefined ? undefined : { kind: 'webauthn', response };
|
|
952
|
+
}
|
|
953
|
+
/** The wire shape of what a browser needs for `navigator.credentials.get`. */
|
|
954
|
+
function assertionOptionsJson(options) {
|
|
955
|
+
return {
|
|
956
|
+
challenge: options.challenge,
|
|
957
|
+
rp_id: options.rpId,
|
|
958
|
+
allow_credentials: options.allowCredentials,
|
|
959
|
+
timeout_ms: options.timeoutMs,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/** And for `navigator.credentials.create`. */
|
|
963
|
+
function registrationOptionsJson(options) {
|
|
964
|
+
return {
|
|
965
|
+
challenge: options.challenge,
|
|
966
|
+
rp: { id: options.rp.id, name: options.rp.name },
|
|
967
|
+
user: { id: options.user.id, name: options.user.name, display_name: options.user.displayName },
|
|
968
|
+
algorithms: options.algorithms,
|
|
969
|
+
exclude_credentials: options.excludeCredentials,
|
|
970
|
+
timeout_ms: options.timeoutMs,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
860
973
|
function tokenJson(tokens) {
|
|
861
974
|
return {
|
|
862
975
|
access_token: tokens.accessToken,
|
|
@@ -2152,6 +2265,11 @@ async function handle(req, res, options) {
|
|
|
2152
2265
|
last_used_at: f.lastUsedAt?.toISOString() ?? null,
|
|
2153
2266
|
})),
|
|
2154
2267
|
recovery_codes_left: left,
|
|
2268
|
+
// What this installation can enrol, so the screen draws the
|
|
2269
|
+
// controls that work rather than one that answers 404. The same
|
|
2270
|
+
// "ask, do not assume" rule `GET /v1/auth/methods` exists for: a
|
|
2271
|
+
// deployment with no `NACRE_2FA_KEY` offers `webauthn` alone.
|
|
2272
|
+
kinds: factors.kinds,
|
|
2155
2273
|
}, requestId);
|
|
2156
2274
|
return;
|
|
2157
2275
|
}
|
|
@@ -2170,6 +2288,84 @@ async function handle(req, res, options) {
|
|
|
2170
2288
|
send(res, 201, { id: begun.id, secret: begun.secret, otpauth_url: begun.otpauthUrl, label: named }, requestId);
|
|
2171
2289
|
return;
|
|
2172
2290
|
}
|
|
2291
|
+
/*
|
|
2292
|
+
* Enrolling a key, which is two calls because a ceremony is.
|
|
2293
|
+
*
|
|
2294
|
+
* The first hands out a challenge and what the authenticator needs to
|
|
2295
|
+
* see; the second brings back what it signed. They are separate because
|
|
2296
|
+
* the challenge has to exist in the database before the browser is asked
|
|
2297
|
+
* for anything — a server that made one up when the answer arrived would
|
|
2298
|
+
* be verifying a signature over a number the client chose.
|
|
2299
|
+
*/
|
|
2300
|
+
if (rest === '/webauthn' && req.method === 'POST') {
|
|
2301
|
+
const begun = await factors.beginWebAuthnRegistration(auth.orgId, userId);
|
|
2302
|
+
if (begun === undefined) {
|
|
2303
|
+
// This installation offers TOTP only. 404 rather than a message
|
|
2304
|
+
// about configuration, for the reason the whole block is behind one.
|
|
2305
|
+
const problem = notFound(instance, requestId);
|
|
2306
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
send(res, 201, registrationOptionsJson(begun), requestId);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
if (rest === '/webauthn/finish' && req.method === 'POST') {
|
|
2313
|
+
const it = (body ?? {});
|
|
2314
|
+
const label = typeof it.label === 'string' && it.label.trim() !== '' ? it.label.trim().slice(0, 60) : 'Security key';
|
|
2315
|
+
const fields = ['challenge', 'attestation_object', 'client_data_json'];
|
|
2316
|
+
if (fields.some((f) => typeof it[f] !== 'string' || it[f] === '')) {
|
|
2317
|
+
const problem = badRequest(instance, requestId, "'challenge', 'attestation_object' and 'client_data_json' are required.");
|
|
2318
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
const codes = await factors.finishWebAuthnRegistration(auth.orgId, userId, label, {
|
|
2322
|
+
attestationObject: new Uint8Array(Buffer.from(it.attestation_object, 'base64url')),
|
|
2323
|
+
clientDataJSON: new Uint8Array(Buffer.from(it.client_data_json, 'base64url')),
|
|
2324
|
+
challenge: it.challenge,
|
|
2325
|
+
});
|
|
2326
|
+
if (codes === undefined) {
|
|
2327
|
+
// One refusal for a spent challenge, a wrong origin, an unsupported
|
|
2328
|
+
// algorithm and a signature that does not verify. Telling them apart
|
|
2329
|
+
// would describe this server's checks to whoever is probing them.
|
|
2330
|
+
const problem = notFound(instance, requestId);
|
|
2331
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
await options.audit.write({
|
|
2335
|
+
orgId: auth.orgId,
|
|
2336
|
+
actor: `user:${userId}`,
|
|
2337
|
+
action: 'second_factor.enrol',
|
|
2338
|
+
result: 'allow',
|
|
2339
|
+
target: { user_id: userId },
|
|
2340
|
+
detail: { kind: 'webauthn' },
|
|
2341
|
+
requestId,
|
|
2342
|
+
});
|
|
2343
|
+
void notifySecurityChange(options, auth.orgId, userId, 'enrolled', requestId);
|
|
2344
|
+
send(res, 200, { recovery_codes: codes }, requestId);
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
/*
|
|
2348
|
+
* A challenge for proving possession to *this* surface, which exists
|
|
2349
|
+
* because taking a factor off takes a current proof and an assertion
|
|
2350
|
+
* cannot be produced without one.
|
|
2351
|
+
*
|
|
2352
|
+
* `purpose = 'authenticate'` is the same pool a sign-in spends from, and
|
|
2353
|
+
* that is deliberate rather than an oversight: this caller has a session,
|
|
2354
|
+
* so a challenge they can spend on a sign-in buys them nothing they do
|
|
2355
|
+
* not already hold. What must not share a pool is *enrolment*, since
|
|
2356
|
+
* that one is asked for by somebody already signed in and would let a
|
|
2357
|
+
* session mint the input to a ceremony it is not in.
|
|
2358
|
+
*/
|
|
2359
|
+
if (rest === '/webauthn/assert' && req.method === 'POST') {
|
|
2360
|
+
const begun = await factors.beginWebAuthnAssertion(auth.orgId, userId);
|
|
2361
|
+
if (begun === undefined) {
|
|
2362
|
+
const problem = notFound(instance, requestId);
|
|
2363
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
send(res, 200, assertionOptionsJson(begun), requestId);
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2173
2369
|
if (rest.endsWith('/confirm') && req.method === 'POST') {
|
|
2174
2370
|
const id = rest.slice(1, -'/confirm'.length);
|
|
2175
2371
|
const code = body?.code;
|
|
@@ -2206,21 +2402,43 @@ async function handle(req, res, options) {
|
|
|
2206
2402
|
send(res, 200, { recovery_codes: codes }, requestId);
|
|
2207
2403
|
return;
|
|
2208
2404
|
}
|
|
2209
|
-
|
|
2405
|
+
/*
|
|
2406
|
+
* Taking a factor off, and the condition is what it is because the one
|
|
2407
|
+
* it replaced could never be true. `rest` is `/{id}` for this route, so
|
|
2408
|
+
* `!rest.includes('/')` asked whether a string beginning with a slash
|
|
2409
|
+
* contains one — false for every id, always. `DELETE
|
|
2410
|
+
* /v1/me/second-factor/{id}` answered 404 from the day it was written
|
|
2411
|
+
* and shipped that way in 0.18.0: a second factor could be enrolled and
|
|
2412
|
+
* never removed, on the one surface an administrator deliberately
|
|
2413
|
+
* cannot reach on somebody's behalf.
|
|
2414
|
+
*
|
|
2415
|
+
* No suite could see it. `second-factor-live.test.ts` calls
|
|
2416
|
+
* `factors.remove` directly and passes, because the store was never the
|
|
2417
|
+
* broken half — nothing had asked the *server* for this path.
|
|
2418
|
+
*/
|
|
2419
|
+
if (rest.startsWith('/') && !rest.slice(1).includes('/') && req.method === 'DELETE') {
|
|
2210
2420
|
const id = rest.slice(1);
|
|
2211
|
-
const code = body
|
|
2212
|
-
|
|
2213
|
-
|
|
2421
|
+
const { code, assertion } = (body ?? {});
|
|
2422
|
+
const proof = readProof(code, assertion);
|
|
2423
|
+
if (!UUID_SHAPE.test(id) || proof === undefined) {
|
|
2424
|
+
const problem = badRequest(instance, requestId, "Either 'code' or 'assertion' is required to remove a second factor.");
|
|
2214
2425
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2215
2426
|
return;
|
|
2216
2427
|
}
|
|
2217
2428
|
/*
|
|
2218
|
-
* A current
|
|
2429
|
+
* A current proof to take one off, and that is the whole reason this
|
|
2219
2430
|
* endpoint takes a body at all. Removing the second factor is the first
|
|
2220
2431
|
* thing somebody with a stolen session does, and a session is exactly
|
|
2221
2432
|
* what the factor exists to be more than.
|
|
2433
|
+
*
|
|
2434
|
+
* Either kind, because an account can hold either kind — an
|
|
2435
|
+
* installation with no `NACRE_2FA_KEY` has no code to ask for, and
|
|
2436
|
+
* demanding one there would make every WebAuthn factor permanent.
|
|
2222
2437
|
*/
|
|
2223
|
-
|
|
2438
|
+
const proved = proof.kind === 'code'
|
|
2439
|
+
? await factors.verify(auth.orgId, userId, proof.code)
|
|
2440
|
+
: await factors.verifyWebAuthnAssertion(auth.orgId, userId, proof.response);
|
|
2441
|
+
if (!proved) {
|
|
2224
2442
|
const problem = notFound(instance, requestId);
|
|
2225
2443
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2226
2444
|
return;
|