@nacre.work/api 0.18.0 → 0.20.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 +140 -4
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +176 -24
- package/dist/login.js.map +1 -1
- package/dist/main.js +41 -15
- package/dist/main.js.map +1 -1
- package/dist/principals.d.ts +33 -2
- package/dist/principals.d.ts.map +1 -1
- package/dist/principals.js +13 -5
- package/dist/principals.js.map +1 -1
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +21 -4
- package/dist/recovery.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 +618 -130
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -118,6 +118,11 @@ function userJson(u) {
|
|
|
118
118
|
// account, which is a fact an administrator needs and which says nothing
|
|
119
119
|
// about the credential.
|
|
120
120
|
has_password: u.hasPassword,
|
|
121
|
+
// Whether several people hold this password. An administrator who ticked
|
|
122
|
+
// that box when creating the account has no other way to see it afterwards,
|
|
123
|
+
// and it decides whether the person on the other end can hold a second
|
|
124
|
+
// factor at all.
|
|
125
|
+
shared: u.shared,
|
|
121
126
|
};
|
|
122
127
|
}
|
|
123
128
|
function groupJson(g) {
|
|
@@ -563,8 +568,331 @@ function handledTooBusy(res, error, instance, requestId) {
|
|
|
563
568
|
send(res, problem.status, problem.toJSON(), requestId, { 'retry-after': '2' });
|
|
564
569
|
return true;
|
|
565
570
|
}
|
|
571
|
+
/**
|
|
572
|
+
* Whether this principal holds its own credentials, and may therefore change
|
|
573
|
+
* them.
|
|
574
|
+
*
|
|
575
|
+
* One predicate for the whole `/v1/me` credential surface — the password and
|
|
576
|
+
* both kinds of second factor — because the question each of those routes asks
|
|
577
|
+
* is the same one, and a route that asked it in its own words is a route the
|
|
578
|
+
* next one is written without.
|
|
579
|
+
*
|
|
580
|
+
* Three classes answer no, and the third is the one this exists for.
|
|
581
|
+
*
|
|
582
|
+
* A **service account** is a key and has nobody to carry an authenticator. A
|
|
583
|
+
* **delegation** is a third party acting for somebody, and changing how that
|
|
584
|
+
* somebody signs in is not what was approved. And a **shared account** is a
|
|
585
|
+
* credential more than one person holds — a published demo login is the case —
|
|
586
|
+
* so there is no "the person" to hold a factor, and the first holder to enrol
|
|
587
|
+
* one locks out every other one with no administrative route back, because an
|
|
588
|
+
* administrator deliberately cannot remove a second factor.
|
|
589
|
+
*
|
|
590
|
+
* `404` rather than `403` on a refusal, which is what the two existing classes
|
|
591
|
+
* already answered: this is a surface that is not there for this principal
|
|
592
|
+
* rather than one it is being kept out of.
|
|
593
|
+
*
|
|
594
|
+
* The `shared` read is per request rather than a token claim. A claim is fixed
|
|
595
|
+
* when the session is minted, so marking an account shared would leave its
|
|
596
|
+
* holder fifteen minutes in which the surface is still open — which is the
|
|
597
|
+
* argument a delegation's `disabled` check already makes, and this is the same
|
|
598
|
+
* kind of fact.
|
|
599
|
+
*/
|
|
600
|
+
async function holdsOwnCredentials(auth, options) {
|
|
601
|
+
if (auth.principal.type !== 'user' || auth.delegation !== undefined)
|
|
602
|
+
return false;
|
|
603
|
+
// Unset in a harness that does not mount the principals surface, and there
|
|
604
|
+
// an account cannot have been marked shared in the first place.
|
|
605
|
+
if (options.users === undefined)
|
|
606
|
+
return true;
|
|
607
|
+
return !(await options.users.isShared(auth.orgId, auth.principal.id));
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The `/v1/me/second-factor` routes, reachable through either door.
|
|
611
|
+
*
|
|
612
|
+
* One implementation with the door as a parameter, rather than a second copy
|
|
613
|
+
* behind the enrolment challenge. Four of these seven routes would have been
|
|
614
|
+
* duplicated, and a fix applied to one copy and not its sibling is the defect
|
|
615
|
+
* this repository names most often.
|
|
616
|
+
*/
|
|
617
|
+
async function handleSecondFactorRoutes(req, res, instance, requestId, body, options, factors, door) {
|
|
618
|
+
const { orgId, userId } = door;
|
|
619
|
+
const rest = instance.slice('/v1/me/second-factor'.length);
|
|
620
|
+
// Default-deny, and the door says what it offers rather than the routes
|
|
621
|
+
// saying who may reach them. A route added later is unreachable through the
|
|
622
|
+
// narrow door until somebody widens it deliberately, which is the direction
|
|
623
|
+
// to be wrong in.
|
|
624
|
+
if (!door.offers(rest, req.method ?? 'GET')) {
|
|
625
|
+
const problem = notFound(instance, requestId);
|
|
626
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (rest === '' && req.method === 'GET') {
|
|
630
|
+
const [items, left] = await Promise.all([
|
|
631
|
+
factors.list(orgId, userId),
|
|
632
|
+
factors.recoveryCodesLeft(orgId, userId),
|
|
633
|
+
]);
|
|
634
|
+
send(res, 200, {
|
|
635
|
+
items: items.map((f) => ({
|
|
636
|
+
id: f.id,
|
|
637
|
+
kind: f.kind,
|
|
638
|
+
label: f.label,
|
|
639
|
+
created_at: f.createdAt.toISOString(),
|
|
640
|
+
last_used_at: f.lastUsedAt?.toISOString() ?? null,
|
|
641
|
+
})),
|
|
642
|
+
recovery_codes_left: left,
|
|
643
|
+
// What this installation can enrol, so the screen draws the
|
|
644
|
+
// controls that work rather than one that answers 404. The same
|
|
645
|
+
// "ask, do not assume" rule `GET /v1/auth/methods` exists for: a
|
|
646
|
+
// deployment with no `NACRE_2FA_KEY` offers `webauthn` alone.
|
|
647
|
+
kinds: factors.kinds,
|
|
648
|
+
}, requestId);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (rest === '' && req.method === 'POST') {
|
|
652
|
+
const label = body?.label;
|
|
653
|
+
const named = typeof label === 'string' && label.trim() !== '' ? label.trim().slice(0, 60) : 'Authenticator';
|
|
654
|
+
const begun = await factors.begin(orgId, userId, named);
|
|
655
|
+
if (begun === undefined) {
|
|
656
|
+
const problem = notFound(instance, requestId);
|
|
657
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// The secret in the response and nowhere else: this is the one moment
|
|
661
|
+
// it exists outside the sealed column, exactly as a generated password
|
|
662
|
+
// is.
|
|
663
|
+
send(res, 201, { id: begun.id, secret: begun.secret, otpauth_url: begun.otpauthUrl, label: named }, requestId);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
/*
|
|
667
|
+
* Enrolling a key, which is two calls because a ceremony is.
|
|
668
|
+
*
|
|
669
|
+
* The first hands out a challenge and what the authenticator needs to
|
|
670
|
+
* see; the second brings back what it signed. They are separate because
|
|
671
|
+
* the challenge has to exist in the database before the browser is asked
|
|
672
|
+
* for anything — a server that made one up when the answer arrived would
|
|
673
|
+
* be verifying a signature over a number the client chose.
|
|
674
|
+
*/
|
|
675
|
+
if (rest === '/webauthn' && req.method === 'POST') {
|
|
676
|
+
const begun = await factors.beginWebAuthnRegistration(orgId, userId);
|
|
677
|
+
if (begun === undefined) {
|
|
678
|
+
// This installation offers TOTP only. 404 rather than a message
|
|
679
|
+
// about configuration, for the reason the whole block is behind one.
|
|
680
|
+
const problem = notFound(instance, requestId);
|
|
681
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
send(res, 201, registrationOptionsJson(begun), requestId);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (rest === '/webauthn/finish' && req.method === 'POST') {
|
|
688
|
+
const it = (body ?? {});
|
|
689
|
+
const label = typeof it.label === 'string' && it.label.trim() !== '' ? it.label.trim().slice(0, 60) : 'Security key';
|
|
690
|
+
const fields = ['challenge', 'attestation_object', 'client_data_json'];
|
|
691
|
+
if (fields.some((f) => typeof it[f] !== 'string' || it[f] === '')) {
|
|
692
|
+
const problem = badRequest(instance, requestId, "'challenge', 'attestation_object' and 'client_data_json' are required.");
|
|
693
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const codes = await factors.finishWebAuthnRegistration(orgId, userId, label, {
|
|
697
|
+
attestationObject: new Uint8Array(Buffer.from(it.attestation_object, 'base64url')),
|
|
698
|
+
clientDataJSON: new Uint8Array(Buffer.from(it.client_data_json, 'base64url')),
|
|
699
|
+
challenge: it.challenge,
|
|
700
|
+
});
|
|
701
|
+
if (codes === undefined) {
|
|
702
|
+
// One refusal for a spent challenge, a wrong origin, an unsupported
|
|
703
|
+
// algorithm and a signature that does not verify. Telling them apart
|
|
704
|
+
// would describe this server's checks to whoever is probing them.
|
|
705
|
+
const problem = notFound(instance, requestId);
|
|
706
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
await options.audit.write({
|
|
710
|
+
orgId: orgId,
|
|
711
|
+
actor: `user:${userId}`,
|
|
712
|
+
action: 'second_factor.enrol',
|
|
713
|
+
result: 'allow',
|
|
714
|
+
target: { user_id: userId },
|
|
715
|
+
detail: { kind: 'webauthn' },
|
|
716
|
+
requestId,
|
|
717
|
+
});
|
|
718
|
+
void notifySecurityChange(options, orgId, userId, 'enrolled', requestId);
|
|
719
|
+
send(res, 200, { recovery_codes: codes, ...(await door.onEnrolled()) }, requestId);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
/*
|
|
723
|
+
* A challenge for proving possession to *this* surface, which exists
|
|
724
|
+
* because taking a factor off takes a current proof and an assertion
|
|
725
|
+
* cannot be produced without one.
|
|
726
|
+
*
|
|
727
|
+
* `purpose = 'authenticate'` is the same pool a sign-in spends from, and
|
|
728
|
+
* that is deliberate rather than an oversight: this caller has a session,
|
|
729
|
+
* so a challenge they can spend on a sign-in buys them nothing they do
|
|
730
|
+
* not already hold. What must not share a pool is *enrolment*, since
|
|
731
|
+
* that one is asked for by somebody already signed in and would let a
|
|
732
|
+
* session mint the input to a ceremony it is not in.
|
|
733
|
+
*/
|
|
734
|
+
if (rest === '/webauthn/assert' && req.method === 'POST') {
|
|
735
|
+
const begun = await factors.beginWebAuthnAssertion(orgId, userId);
|
|
736
|
+
if (begun === undefined) {
|
|
737
|
+
const problem = notFound(instance, requestId);
|
|
738
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
send(res, 200, assertionOptionsJson(begun), requestId);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (rest.endsWith('/confirm') && req.method === 'POST') {
|
|
745
|
+
const id = rest.slice(1, -'/confirm'.length);
|
|
746
|
+
const code = body?.code;
|
|
747
|
+
if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
|
|
748
|
+
const problem = badRequest(instance, requestId, "'code' is required.");
|
|
749
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const codes = await factors.confirm(orgId, userId, id, code);
|
|
753
|
+
if (codes === undefined) {
|
|
754
|
+
// One refusal for a wrong code and for an enrolment that is not
|
|
755
|
+
// there. Telling them apart would say whether a given id exists.
|
|
756
|
+
const problem = notFound(instance, requestId);
|
|
757
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
await options.audit.write({
|
|
761
|
+
orgId: orgId,
|
|
762
|
+
actor: `user:${userId}`,
|
|
763
|
+
action: 'second_factor.enrol',
|
|
764
|
+
result: 'allow',
|
|
765
|
+
target: { user_id: userId },
|
|
766
|
+
detail: {},
|
|
767
|
+
requestId,
|
|
768
|
+
});
|
|
769
|
+
// A notice, where the deployment can send one. The person who did not
|
|
770
|
+
// do this is the one who needs to know, and a second factor appearing
|
|
771
|
+
// on an account is exactly what somebody taking it over would do.
|
|
772
|
+
// Dropped rather than raised: the enrolment happened.
|
|
773
|
+
void notifySecurityChange(options, orgId, userId, 'enrolled', requestId);
|
|
774
|
+
// Printed once. A second call returns an empty list rather than new
|
|
775
|
+
// codes, because reissuing them here would invalidate the set somebody
|
|
776
|
+
// has already written down.
|
|
777
|
+
send(res, 200, { recovery_codes: codes, ...(await door.onEnrolled()) }, requestId);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
/*
|
|
781
|
+
* Taking a factor off, and the condition is what it is because the one
|
|
782
|
+
* it replaced could never be true. `rest` is `/{id}` for this route, so
|
|
783
|
+
* `!rest.includes('/')` asked whether a string beginning with a slash
|
|
784
|
+
* contains one — false for every id, always. `DELETE
|
|
785
|
+
* /v1/me/second-factor/{id}` answered 404 from the day it was written
|
|
786
|
+
* and shipped that way in 0.18.0: a second factor could be enrolled and
|
|
787
|
+
* never removed, on the one surface an administrator deliberately
|
|
788
|
+
* cannot reach on somebody's behalf.
|
|
789
|
+
*
|
|
790
|
+
* No suite could see it. `second-factor-live.test.ts` calls
|
|
791
|
+
* `factors.remove` directly and passes, because the store was never the
|
|
792
|
+
* broken half — nothing had asked the *server* for this path.
|
|
793
|
+
*/
|
|
794
|
+
if (rest.startsWith('/') && !rest.slice(1).includes('/') && req.method === 'DELETE') {
|
|
795
|
+
const id = rest.slice(1);
|
|
796
|
+
const { code, assertion } = (body ?? {});
|
|
797
|
+
const proof = readProof(code, assertion);
|
|
798
|
+
if (!UUID_SHAPE.test(id) || proof === undefined) {
|
|
799
|
+
const problem = badRequest(instance, requestId, "Either 'code' or 'assertion' is required to remove a second factor.");
|
|
800
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
/*
|
|
804
|
+
* A current proof to take one off, and that is the whole reason this
|
|
805
|
+
* endpoint takes a body at all. Removing the second factor is the first
|
|
806
|
+
* thing somebody with a stolen session does, and a session is exactly
|
|
807
|
+
* what the factor exists to be more than.
|
|
808
|
+
*
|
|
809
|
+
* Either kind, because an account can hold either kind — an
|
|
810
|
+
* installation with no `NACRE_2FA_KEY` has no code to ask for, and
|
|
811
|
+
* demanding one there would make every WebAuthn factor permanent.
|
|
812
|
+
*/
|
|
813
|
+
const proved = proof.kind === 'code'
|
|
814
|
+
? await factors.verify(orgId, userId, proof.code)
|
|
815
|
+
: await factors.verifyWebAuthnAssertion(orgId, userId, proof.response);
|
|
816
|
+
if (!proved) {
|
|
817
|
+
const problem = notFound(instance, requestId);
|
|
818
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const removed = await factors.remove(orgId, userId, id);
|
|
822
|
+
if (!removed) {
|
|
823
|
+
const problem = notFound(instance, requestId);
|
|
824
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
await options.audit.write({
|
|
828
|
+
orgId: orgId,
|
|
829
|
+
actor: `user:${userId}`,
|
|
830
|
+
action: 'second_factor.remove',
|
|
831
|
+
result: 'allow',
|
|
832
|
+
target: { user_id: userId },
|
|
833
|
+
detail: {},
|
|
834
|
+
requestId,
|
|
835
|
+
});
|
|
836
|
+
void notifySecurityChange(options, orgId, userId, 'removed', requestId);
|
|
837
|
+
send(res, 204, null, requestId);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const problem = notFound(instance, requestId);
|
|
841
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* The two session outcomes that are not a session, rendered in one place.
|
|
846
|
+
*
|
|
847
|
+
* Four handlers can reach them — sign-in, the second half of a sign-in, a
|
|
848
|
+
* refresh, and a password change — because the gates are consulted where a
|
|
849
|
+
* session is minted and all four mint one. Four copies of this rendering is
|
|
850
|
+
* four chances to answer one of them with a `200` and no tokens, which a client
|
|
851
|
+
* reads as a successful sign-in that produced nothing.
|
|
852
|
+
*
|
|
853
|
+
* `enrol` is a **200**, on the same argument the second-factor challenge is:
|
|
854
|
+
* nothing was refused. The client is being asked for something more, and told
|
|
855
|
+
* where to send it. `second_factor_enrolment_required` rather than reusing
|
|
856
|
+
* `second_factor_required`, because the two ask for different things — one for
|
|
857
|
+
* a code from an authenticator that exists, one for an authenticator.
|
|
858
|
+
*
|
|
859
|
+
* `refuse` is a **403**. Not `401`: the credential was correct, and every
|
|
860
|
+
* client here renews on a `401` and replays, so a policy refusal spelled that
|
|
861
|
+
* way would spend a refresh token and arrive as two failures. Not `404` either
|
|
862
|
+
* — the caller is looking straight at their own account. The reason is the
|
|
863
|
+
* gate's own words, because a refusal a person cannot act on and cannot read is
|
|
864
|
+
* indistinguishable from the product being broken.
|
|
865
|
+
*/
|
|
866
|
+
function sendSessionOutcome(res, outcome, instance, requestId) {
|
|
867
|
+
if (outcome.kind === 'enrol-second-factor') {
|
|
868
|
+
send(res, 200, {
|
|
869
|
+
second_factor_enrolment_required: true,
|
|
870
|
+
challenge: outcome.challenge,
|
|
871
|
+
expires_in: outcome.expiresIn,
|
|
872
|
+
reason: outcome.reason,
|
|
873
|
+
}, requestId);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const problem = new Problem({
|
|
877
|
+
type: 'https://nacre.work/errors/sign-in-refused',
|
|
878
|
+
title: 'Sign-in refused',
|
|
879
|
+
status: 403,
|
|
880
|
+
detail: outcome.reason,
|
|
881
|
+
instance,
|
|
882
|
+
requestId,
|
|
883
|
+
});
|
|
884
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
885
|
+
}
|
|
566
886
|
async function handleAuth(req, res, instance, requestId, options) {
|
|
567
|
-
|
|
887
|
+
// Every route here produces a credential and therefore takes a body, with
|
|
888
|
+
// exactly one exception: `/v1/auth/methods` is a **read**, and the contract
|
|
889
|
+
// has said `get` since it was written. A blanket `!== 'POST'` refused it, so
|
|
890
|
+
// the endpoint whose whole job is telling a sign-in screen what exists
|
|
891
|
+
// answered 404 — and the console, reading `password_reset` off a problem
|
|
892
|
+
// document, hid the recovery link on every deployment including the ones
|
|
893
|
+
// with a relay configured. The feature did the opposite of its purpose.
|
|
894
|
+
const reads = instance === '/v1/auth/methods';
|
|
895
|
+
if (options.login === undefined || req.method !== (reads ? 'GET' : 'POST')) {
|
|
568
896
|
const problem = notFound(instance, requestId);
|
|
569
897
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
570
898
|
return;
|
|
@@ -673,6 +1001,13 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
673
1001
|
}, requestId);
|
|
674
1002
|
return;
|
|
675
1003
|
}
|
|
1004
|
+
if (outcome.kind !== 'tokens') {
|
|
1005
|
+
// A gate answered. Not an audit event either way: `enrol` is half an
|
|
1006
|
+
// authentication, like the challenge above, and `refused` never became a
|
|
1007
|
+
// session — the `login allow` event is written where one is issued.
|
|
1008
|
+
sendSessionOutcome(res, outcome, instance, requestId);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
676
1011
|
const tokens = outcome.tokens;
|
|
677
1012
|
// The successful one does have an organization to belong to, and it is the
|
|
678
1013
|
// event that answers "who has been in here". Awaited: a lost audit event is
|
|
@@ -701,7 +1036,14 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
701
1036
|
* would learn by pressing the link anyway. It says nothing about any address.
|
|
702
1037
|
*/
|
|
703
1038
|
if (instance === '/v1/auth/methods') {
|
|
704
|
-
send(res, 200, {
|
|
1039
|
+
send(res, 200, {
|
|
1040
|
+
password_reset: options.recovery !== undefined,
|
|
1041
|
+
// Which second factors this installation can *challenge* with. A
|
|
1042
|
+
// sign-in screen that offered "use your security key" where the
|
|
1043
|
+
// deployment has no relying party would be the same defect the
|
|
1044
|
+
// recovery link was: a control the server refuses.
|
|
1045
|
+
second_factor_kinds: options.secondFactors?.kinds ?? [],
|
|
1046
|
+
}, requestId);
|
|
705
1047
|
return;
|
|
706
1048
|
}
|
|
707
1049
|
/*
|
|
@@ -784,10 +1126,41 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
784
1126
|
* survives a Redis restart; this one is the bound that costs an attacker
|
|
785
1127
|
* their source.
|
|
786
1128
|
*/
|
|
1129
|
+
/*
|
|
1130
|
+
* The options a browser needs before it can produce an assertion.
|
|
1131
|
+
*
|
|
1132
|
+
* It takes the sign-in challenge and nothing else, so this is not a route
|
|
1133
|
+
* that will tell a stranger which authenticators an address holds: the
|
|
1134
|
+
* challenge is a JWT this server signed one password ago. Not rate limited
|
|
1135
|
+
* separately, because reaching it costs a correct password.
|
|
1136
|
+
*/
|
|
1137
|
+
if (instance === '/v1/auth/second-factor/webauthn') {
|
|
1138
|
+
const challenge = body?.challenge;
|
|
1139
|
+
if (typeof challenge !== 'string') {
|
|
1140
|
+
const problem = badRequest(instance, requestId, "'challenge' is required.");
|
|
1141
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const begun = await options.login.beginSecondFactorWebAuthn(challenge);
|
|
1145
|
+
if (begun === undefined) {
|
|
1146
|
+
// One refusal for a challenge that is not ours, one that has expired, and
|
|
1147
|
+
// a person holding no key — the same rule the rest of this path follows.
|
|
1148
|
+
refuse();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
send(res, 200, assertionOptionsJson(begun), requestId);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
787
1154
|
if (instance === '/v1/auth/second-factor') {
|
|
788
|
-
const { challenge, code } = (body ?? {});
|
|
789
|
-
if (typeof challenge !== 'string'
|
|
790
|
-
const problem = badRequest(instance, requestId, "'challenge'
|
|
1155
|
+
const { challenge, code, assertion } = (body ?? {});
|
|
1156
|
+
if (typeof challenge !== 'string') {
|
|
1157
|
+
const problem = badRequest(instance, requestId, "'challenge' is required.");
|
|
1158
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const proof = readProof(code, assertion);
|
|
1162
|
+
if (proof === undefined) {
|
|
1163
|
+
const problem = badRequest(instance, requestId, "Either 'code' or 'assertion' is required.");
|
|
791
1164
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
792
1165
|
return;
|
|
793
1166
|
}
|
|
@@ -809,8 +1182,8 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
809
1182
|
}
|
|
810
1183
|
}
|
|
811
1184
|
}
|
|
812
|
-
const
|
|
813
|
-
if (
|
|
1185
|
+
const outcome = await options.login.completeSecondFactor(challenge, proof);
|
|
1186
|
+
if (outcome === undefined) {
|
|
814
1187
|
// One refusal for an expired challenge, a forged one, a wrong code and a
|
|
815
1188
|
// disabled account alike. Which of the four it was is nothing a client
|
|
816
1189
|
// needs and something an attacker would use.
|
|
@@ -818,6 +1191,14 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
818
1191
|
refuse();
|
|
819
1192
|
return;
|
|
820
1193
|
}
|
|
1194
|
+
if (outcome.kind !== 'tokens') {
|
|
1195
|
+
// A gate that wanted a *particular* kind can still refuse the one that
|
|
1196
|
+
// was just produced, which is why the gates run after the proof rather
|
|
1197
|
+
// than instead of it.
|
|
1198
|
+
sendSessionOutcome(res, outcome, instance, requestId);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const tokens = outcome.tokens;
|
|
821
1202
|
await options.audit.write({
|
|
822
1203
|
orgId: tokens.orgId,
|
|
823
1204
|
actor: `user:${tokens.userId}`,
|
|
@@ -825,8 +1206,10 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
825
1206
|
result: 'allow',
|
|
826
1207
|
target: { user_id: tokens.userId },
|
|
827
1208
|
// 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
|
-
|
|
1209
|
+
// and "signed in with a password" are different facts to an investigator
|
|
1210
|
+
// — and so are the two kinds of second factor, since only one of them is
|
|
1211
|
+
// proof against a page that looked like this one.
|
|
1212
|
+
detail: { second_factor: true, second_factor_kind: proof.kind },
|
|
830
1213
|
requestId,
|
|
831
1214
|
});
|
|
832
1215
|
send(res, 200, tokenJson(tokens), requestId);
|
|
@@ -839,12 +1222,21 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
839
1222
|
return;
|
|
840
1223
|
}
|
|
841
1224
|
if (instance === '/v1/auth/refresh') {
|
|
842
|
-
const
|
|
843
|
-
if (
|
|
1225
|
+
const outcome = await options.login.refresh(token);
|
|
1226
|
+
if (outcome === undefined) {
|
|
844
1227
|
refuse();
|
|
845
1228
|
return;
|
|
846
1229
|
}
|
|
847
|
-
|
|
1230
|
+
if (outcome.kind !== 'tokens') {
|
|
1231
|
+
// A renewal is gated like a sign-in, so a policy turned on while people
|
|
1232
|
+
// are signed in reaches them rather than waiting for every session to
|
|
1233
|
+
// expire. The refresh token is spent either way — the claim commits
|
|
1234
|
+
// before the gates are asked — so this is the end of that session and the
|
|
1235
|
+
// challenge is the way on.
|
|
1236
|
+
sendSessionOutcome(res, outcome, instance, requestId);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
send(res, 200, tokenJson(outcome.tokens), requestId);
|
|
848
1240
|
return;
|
|
849
1241
|
}
|
|
850
1242
|
if (instance === '/v1/auth/logout') {
|
|
@@ -857,6 +1249,71 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
857
1249
|
const problem = notFound(instance, requestId);
|
|
858
1250
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
859
1251
|
}
|
|
1252
|
+
/**
|
|
1253
|
+
* A WebAuthn assertion out of a request body, or nothing.
|
|
1254
|
+
*
|
|
1255
|
+
* One parser, because two routes take one: the second half of a sign-in and
|
|
1256
|
+
* taking a factor off an account. Two would be two ideas of which fields are
|
|
1257
|
+
* required and which encoding they arrive in, and the half that got it wrong
|
|
1258
|
+
* would refuse every authenticator with a message about neither.
|
|
1259
|
+
*
|
|
1260
|
+
* base64url throughout, which is what a browser reports and what
|
|
1261
|
+
* `webauthn_challenges.challenge` stores — so the comparison downstream is a
|
|
1262
|
+
* string equality and never an encoding round trip.
|
|
1263
|
+
*/
|
|
1264
|
+
function readAssertion(value) {
|
|
1265
|
+
if (typeof value !== 'object' || value === null)
|
|
1266
|
+
return undefined;
|
|
1267
|
+
const it = value;
|
|
1268
|
+
const fields = ['credential_id', 'authenticator_data', 'client_data_json', 'signature', 'challenge'];
|
|
1269
|
+
if (fields.some((f) => typeof it[f] !== 'string' || it[f] === ''))
|
|
1270
|
+
return undefined;
|
|
1271
|
+
const bytes = (name) => new Uint8Array(Buffer.from(it[name], 'base64url'));
|
|
1272
|
+
return {
|
|
1273
|
+
credentialId: it.credential_id,
|
|
1274
|
+
authenticatorData: bytes('authenticator_data'),
|
|
1275
|
+
clientDataJSON: bytes('client_data_json'),
|
|
1276
|
+
signature: bytes('signature'),
|
|
1277
|
+
challenge: it.challenge,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Which of the two proofs a body carries.
|
|
1282
|
+
*
|
|
1283
|
+
* Both present is a refusal rather than a precedence. A caller sending each is
|
|
1284
|
+
* a caller with two ideas of how it is signing in, and resolving that by
|
|
1285
|
+
* preferring one leaves the other apparently offered and ignored — which is
|
|
1286
|
+
* the argument `loadConfig` makes about a secret and a key ref together.
|
|
1287
|
+
*/
|
|
1288
|
+
function readProof(code, assertion) {
|
|
1289
|
+
const hasCode = typeof code === 'string' && code !== '';
|
|
1290
|
+
if (hasCode && assertion !== undefined)
|
|
1291
|
+
return undefined;
|
|
1292
|
+
if (hasCode)
|
|
1293
|
+
return { kind: 'code', code: code };
|
|
1294
|
+
const response = readAssertion(assertion);
|
|
1295
|
+
return response === undefined ? undefined : { kind: 'webauthn', response };
|
|
1296
|
+
}
|
|
1297
|
+
/** The wire shape of what a browser needs for `navigator.credentials.get`. */
|
|
1298
|
+
function assertionOptionsJson(options) {
|
|
1299
|
+
return {
|
|
1300
|
+
challenge: options.challenge,
|
|
1301
|
+
rp_id: options.rpId,
|
|
1302
|
+
allow_credentials: options.allowCredentials,
|
|
1303
|
+
timeout_ms: options.timeoutMs,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
/** And for `navigator.credentials.create`. */
|
|
1307
|
+
function registrationOptionsJson(options) {
|
|
1308
|
+
return {
|
|
1309
|
+
challenge: options.challenge,
|
|
1310
|
+
rp: { id: options.rp.id, name: options.rp.name },
|
|
1311
|
+
user: { id: options.user.id, name: options.user.name, display_name: options.user.displayName },
|
|
1312
|
+
algorithms: options.algorithms,
|
|
1313
|
+
exclude_credentials: options.excludeCredentials,
|
|
1314
|
+
timeout_ms: options.timeoutMs,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
860
1317
|
function tokenJson(tokens) {
|
|
861
1318
|
return {
|
|
862
1319
|
access_token: tokens.accessToken,
|
|
@@ -1386,6 +1843,87 @@ async function handle(req, res, options) {
|
|
|
1386
1843
|
: presented.slice(7).startsWith('nacre_sk_')
|
|
1387
1844
|
? 'service_key'
|
|
1388
1845
|
: 'jwt';
|
|
1846
|
+
/*
|
|
1847
|
+
* The narrow door, and it is reached only by a credential this API has
|
|
1848
|
+
* already refused as an access token — which is exactly what an enrolment
|
|
1849
|
+
* challenge is, since its audience is deliberately not the API's.
|
|
1850
|
+
*
|
|
1851
|
+
* Placed here rather than ahead of `authenticate` for two reasons, and the
|
|
1852
|
+
* first was found by running the suite: ahead of it, every ordinary request
|
|
1853
|
+
* to this path paid a JWT verification for a token it was never carrying.
|
|
1854
|
+
* The second is the better one — down here the door cannot widen anything,
|
|
1855
|
+
* because the only requests that reach it are ones already on their way to
|
|
1856
|
+
* a 401.
|
|
1857
|
+
*
|
|
1858
|
+
* That is also what makes it safe to be wrong about. A route that forgets
|
|
1859
|
+
* this door answers 401, never 200. A design that instead minted a real
|
|
1860
|
+
* access token and asked every other route to refuse it would fail the
|
|
1861
|
+
* other way, and on an authorization boundary that is the dangerous
|
|
1862
|
+
* direction.
|
|
1863
|
+
*
|
|
1864
|
+
* Keyed on the instance, so nothing else in the API is reachable with one
|
|
1865
|
+
* of these however this branch is edited. The door then narrows further, to
|
|
1866
|
+
* the four routes that add a factor.
|
|
1867
|
+
*/
|
|
1868
|
+
if ((instance === '/v1/me/second-factor' || instance.startsWith('/v1/me/second-factor/')) &&
|
|
1869
|
+
options.login !== undefined &&
|
|
1870
|
+
options.secondFactors !== undefined &&
|
|
1871
|
+
options.secondFactors.available) {
|
|
1872
|
+
const bearer = presented?.startsWith('Bearer ') === true ? presented.slice(7) : undefined;
|
|
1873
|
+
const who = bearer === undefined ? undefined : await options.login.readEnrolmentChallenge(bearer);
|
|
1874
|
+
// A shared account cannot enrol through this door either. It could only
|
|
1875
|
+
// get here by a gate demanding a factor of an account that must not hold
|
|
1876
|
+
// one, and the database refuses the row regardless — so without this the
|
|
1877
|
+
// symptom is a 500 rather than the 404 the wide door answers.
|
|
1878
|
+
const enrolling = who === undefined || options.users === undefined
|
|
1879
|
+
? who
|
|
1880
|
+
: (await options.users.isShared(who.orgId, who.userId))
|
|
1881
|
+
? undefined
|
|
1882
|
+
: who;
|
|
1883
|
+
if (enrolling !== undefined) {
|
|
1884
|
+
const who = enrolling;
|
|
1885
|
+
const login = options.login;
|
|
1886
|
+
const factors = options.secondFactors;
|
|
1887
|
+
let enrolmentBody;
|
|
1888
|
+
try {
|
|
1889
|
+
enrolmentBody = await readBody(req, options.maxBodyBytes ?? MAX_BODY_BYTES);
|
|
1890
|
+
}
|
|
1891
|
+
catch {
|
|
1892
|
+
const problem = badRequest(instance, requestId, 'The request body could not be read.');
|
|
1893
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
await handleSecondFactorRoutes(req, res, instance, requestId, enrolmentBody, options, factors, {
|
|
1897
|
+
orgId: who.orgId,
|
|
1898
|
+
userId: who.userId,
|
|
1899
|
+
// Adding a factor, and nothing else. Not the listing, because a
|
|
1900
|
+
// half-authenticated caller is not owed an inventory of the account;
|
|
1901
|
+
// not the removal, because taking a factor off under a mandate to add
|
|
1902
|
+
// one is the move somebody who has stolen a password would make; and
|
|
1903
|
+
// not the sign-in assertion, which belongs to the other ceremony.
|
|
1904
|
+
offers: (rest, method) => method === 'POST' &&
|
|
1905
|
+
(rest === '' ||
|
|
1906
|
+
rest === '/webauthn' ||
|
|
1907
|
+
rest === '/webauthn/finish' ||
|
|
1908
|
+
(rest.startsWith('/') && rest.endsWith('/confirm'))),
|
|
1909
|
+
// The end of a sign-in as well as the end of an enrolment. The gates
|
|
1910
|
+
// run again inside `sessionAfterEnrolment`, because a gate that
|
|
1911
|
+
// wanted a particular kind is entitled to refuse the one just added.
|
|
1912
|
+
onEnrolled: async () => {
|
|
1913
|
+
const outcome = await login.sessionAfterEnrolment(who.orgId, who.userId);
|
|
1914
|
+
if (outcome === undefined || outcome.kind !== 'tokens')
|
|
1915
|
+
return {};
|
|
1916
|
+
return {
|
|
1917
|
+
access_token: outcome.tokens.accessToken,
|
|
1918
|
+
token_type: 'Bearer',
|
|
1919
|
+
expires_in: outcome.tokens.expiresIn,
|
|
1920
|
+
refresh_token: outcome.tokens.refreshToken,
|
|
1921
|
+
};
|
|
1922
|
+
},
|
|
1923
|
+
});
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1389
1927
|
options.observe?.authFailures.inc({ kind });
|
|
1390
1928
|
send(res, auth.status, auth.toJSON(), requestId);
|
|
1391
1929
|
return;
|
|
@@ -1976,15 +2514,25 @@ async function handle(req, res, options) {
|
|
|
1976
2514
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
1977
2515
|
return;
|
|
1978
2516
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2517
|
+
/*
|
|
2518
|
+
* Composed from the token, plus one read.
|
|
2519
|
+
*
|
|
2520
|
+
* It was from the token and nothing else, on the argument that no query
|
|
2521
|
+
* means no way to grow into naming somebody else — which still holds,
|
|
2522
|
+
* because the read takes the id the token already carries and asks one
|
|
2523
|
+
* boolean about it.
|
|
2524
|
+
*
|
|
2525
|
+
* `holds_own_credentials` is here so a screen can leave the password and
|
|
2526
|
+
* second-factor controls **off** rather than drawing controls that answer
|
|
2527
|
+
* 404. That is the same rule `GET /v1/auth/methods` exists for, applied
|
|
2528
|
+
* to the account instead of to the installation: ask, do not assume.
|
|
2529
|
+
*/
|
|
1983
2530
|
send(res, 200, {
|
|
1984
2531
|
organization: auth.orgId,
|
|
1985
2532
|
principal_type: auth.principal.type,
|
|
1986
2533
|
principal_id: auth.principal.id,
|
|
1987
2534
|
role: auth.role,
|
|
2535
|
+
holds_own_credentials: await holdsOwnCredentials(auth, options),
|
|
1988
2536
|
}, requestId);
|
|
1989
2537
|
return;
|
|
1990
2538
|
}
|
|
@@ -2009,7 +2557,7 @@ async function handle(req, res, options) {
|
|
|
2009
2557
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2010
2558
|
return;
|
|
2011
2559
|
}
|
|
2012
|
-
if (
|
|
2560
|
+
if (!(await holdsOwnCredentials(auth, options))) {
|
|
2013
2561
|
const problem = notFound(instance, requestId);
|
|
2014
2562
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2015
2563
|
return;
|
|
@@ -2066,6 +2614,31 @@ async function handle(req, res, options) {
|
|
|
2066
2614
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2067
2615
|
return;
|
|
2068
2616
|
}
|
|
2617
|
+
if (outcome.kind !== 'changed') {
|
|
2618
|
+
/*
|
|
2619
|
+
* A gate answered, and the password **is** already changed — the
|
|
2620
|
+
* statement committed before the session was minted. So this is not a
|
|
2621
|
+
* failure to report: it is a person who now has a new password and no
|
|
2622
|
+
* session, and the challenge is what gets them one.
|
|
2623
|
+
*
|
|
2624
|
+
* The audit event is written first for that reason. `password.change`
|
|
2625
|
+
* records what happened to the row, and it happened whatever the gate
|
|
2626
|
+
* then said about the session; writing it only on the way to a token
|
|
2627
|
+
* would leave the one action an investigator most wants out of the
|
|
2628
|
+
* journal precisely when a policy is being enforced.
|
|
2629
|
+
*/
|
|
2630
|
+
await options.audit.write({
|
|
2631
|
+
orgId: auth.orgId,
|
|
2632
|
+
actor: `user:${auth.principal.id}`,
|
|
2633
|
+
action: 'password.change',
|
|
2634
|
+
result: 'allow',
|
|
2635
|
+
target: { user_id: auth.principal.id },
|
|
2636
|
+
detail: {},
|
|
2637
|
+
requestId,
|
|
2638
|
+
});
|
|
2639
|
+
sendSessionOutcome(res, outcome, instance, requestId);
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2069
2642
|
await options.audit.write({
|
|
2070
2643
|
orgId: auth.orgId,
|
|
2071
2644
|
actor: `user:${auth.principal.id}`,
|
|
@@ -2130,122 +2703,20 @@ async function handle(req, res, options) {
|
|
|
2130
2703
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2131
2704
|
return;
|
|
2132
2705
|
}
|
|
2133
|
-
if (
|
|
2706
|
+
if (!(await holdsOwnCredentials(auth, options))) {
|
|
2134
2707
|
const problem = notFound(instance, requestId);
|
|
2135
2708
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2136
2709
|
return;
|
|
2137
2710
|
}
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
items: items.map((f) => ({
|
|
2148
|
-
id: f.id,
|
|
2149
|
-
kind: f.kind,
|
|
2150
|
-
label: f.label,
|
|
2151
|
-
created_at: f.createdAt.toISOString(),
|
|
2152
|
-
last_used_at: f.lastUsedAt?.toISOString() ?? null,
|
|
2153
|
-
})),
|
|
2154
|
-
recovery_codes_left: left,
|
|
2155
|
-
}, requestId);
|
|
2156
|
-
return;
|
|
2157
|
-
}
|
|
2158
|
-
if (rest === '' && req.method === 'POST') {
|
|
2159
|
-
const label = body?.label;
|
|
2160
|
-
const named = typeof label === 'string' && label.trim() !== '' ? label.trim().slice(0, 60) : 'Authenticator';
|
|
2161
|
-
const begun = await factors.begin(auth.orgId, userId, named);
|
|
2162
|
-
if (begun === undefined) {
|
|
2163
|
-
const problem = notFound(instance, requestId);
|
|
2164
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2165
|
-
return;
|
|
2166
|
-
}
|
|
2167
|
-
// The secret in the response and nowhere else: this is the one moment
|
|
2168
|
-
// it exists outside the sealed column, exactly as a generated password
|
|
2169
|
-
// is.
|
|
2170
|
-
send(res, 201, { id: begun.id, secret: begun.secret, otpauth_url: begun.otpauthUrl, label: named }, requestId);
|
|
2171
|
-
return;
|
|
2172
|
-
}
|
|
2173
|
-
if (rest.endsWith('/confirm') && req.method === 'POST') {
|
|
2174
|
-
const id = rest.slice(1, -'/confirm'.length);
|
|
2175
|
-
const code = body?.code;
|
|
2176
|
-
if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
|
|
2177
|
-
const problem = badRequest(instance, requestId, "'code' is required.");
|
|
2178
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2179
|
-
return;
|
|
2180
|
-
}
|
|
2181
|
-
const codes = await factors.confirm(auth.orgId, userId, id, code);
|
|
2182
|
-
if (codes === undefined) {
|
|
2183
|
-
// One refusal for a wrong code and for an enrolment that is not
|
|
2184
|
-
// there. Telling them apart would say whether a given id exists.
|
|
2185
|
-
const problem = notFound(instance, requestId);
|
|
2186
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2187
|
-
return;
|
|
2188
|
-
}
|
|
2189
|
-
await options.audit.write({
|
|
2190
|
-
orgId: auth.orgId,
|
|
2191
|
-
actor: `user:${userId}`,
|
|
2192
|
-
action: 'second_factor.enrol',
|
|
2193
|
-
result: 'allow',
|
|
2194
|
-
target: { user_id: userId },
|
|
2195
|
-
detail: {},
|
|
2196
|
-
requestId,
|
|
2197
|
-
});
|
|
2198
|
-
// A notice, where the deployment can send one. The person who did not
|
|
2199
|
-
// do this is the one who needs to know, and a second factor appearing
|
|
2200
|
-
// on an account is exactly what somebody taking it over would do.
|
|
2201
|
-
// Dropped rather than raised: the enrolment happened.
|
|
2202
|
-
void notifySecurityChange(options, auth.orgId, userId, 'enrolled', requestId);
|
|
2203
|
-
// Printed once. A second call returns an empty list rather than new
|
|
2204
|
-
// codes, because reissuing them here would invalidate the set somebody
|
|
2205
|
-
// has already written down.
|
|
2206
|
-
send(res, 200, { recovery_codes: codes }, requestId);
|
|
2207
|
-
return;
|
|
2208
|
-
}
|
|
2209
|
-
if (rest !== '' && !rest.includes('/') && req.method === 'DELETE') {
|
|
2210
|
-
const id = rest.slice(1);
|
|
2211
|
-
const code = body?.code;
|
|
2212
|
-
if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
|
|
2213
|
-
const problem = badRequest(instance, requestId, "'code' is required to remove a second factor.");
|
|
2214
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2215
|
-
return;
|
|
2216
|
-
}
|
|
2217
|
-
/*
|
|
2218
|
-
* A current code to take one off, and that is the whole reason this
|
|
2219
|
-
* endpoint takes a body at all. Removing the second factor is the first
|
|
2220
|
-
* thing somebody with a stolen session does, and a session is exactly
|
|
2221
|
-
* what the factor exists to be more than.
|
|
2222
|
-
*/
|
|
2223
|
-
if (!(await factors.verify(auth.orgId, userId, code))) {
|
|
2224
|
-
const problem = notFound(instance, requestId);
|
|
2225
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2226
|
-
return;
|
|
2227
|
-
}
|
|
2228
|
-
const removed = await factors.remove(auth.orgId, userId, id);
|
|
2229
|
-
if (!removed) {
|
|
2230
|
-
const problem = notFound(instance, requestId);
|
|
2231
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2232
|
-
return;
|
|
2233
|
-
}
|
|
2234
|
-
await options.audit.write({
|
|
2235
|
-
orgId: auth.orgId,
|
|
2236
|
-
actor: `user:${userId}`,
|
|
2237
|
-
action: 'second_factor.remove',
|
|
2238
|
-
result: 'allow',
|
|
2239
|
-
target: { user_id: userId },
|
|
2240
|
-
detail: {},
|
|
2241
|
-
requestId,
|
|
2242
|
-
});
|
|
2243
|
-
void notifySecurityChange(options, auth.orgId, userId, 'removed', requestId);
|
|
2244
|
-
send(res, 204, null, requestId);
|
|
2245
|
-
return;
|
|
2246
|
-
}
|
|
2247
|
-
const problem = notFound(instance, requestId);
|
|
2248
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2711
|
+
// The wide door: a signed-in person reaches all seven routes, and a
|
|
2712
|
+
// completed enrolment adds nothing to the response because they already
|
|
2713
|
+
// hold a session.
|
|
2714
|
+
await handleSecondFactorRoutes(req, res, instance, requestId, body, options, options.secondFactors, {
|
|
2715
|
+
orgId: auth.orgId,
|
|
2716
|
+
userId: auth.principal.id,
|
|
2717
|
+
offers: () => true,
|
|
2718
|
+
onEnrolled: async () => ({}),
|
|
2719
|
+
});
|
|
2249
2720
|
return;
|
|
2250
2721
|
}
|
|
2251
2722
|
if (instance === '/v1/embedding-providers' && options.embeddingProviders !== undefined) {
|
|
@@ -3411,7 +3882,24 @@ async function handle(req, res, options) {
|
|
|
3411
3882
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
3412
3883
|
return;
|
|
3413
3884
|
}
|
|
3414
|
-
|
|
3885
|
+
/*
|
|
3886
|
+
* `shared` is a credential more than one person will hold, and it is
|
|
3887
|
+
* refused rather than coerced when it is not a boolean: a caller that
|
|
3888
|
+
* sent a string got the account they asked for either way, and only one
|
|
3889
|
+
* of the two answers is the one they meant.
|
|
3890
|
+
*
|
|
3891
|
+
* It cannot be changed afterwards, deliberately. Clearing it on an
|
|
3892
|
+
* account whose password is already published would reopen the surface
|
|
3893
|
+
* for whoever holds that password; setting it on somebody's real
|
|
3894
|
+
* account would take their second factor's meaning away without
|
|
3895
|
+
* removing the factor. Both are new accounts, which is cheap.
|
|
3896
|
+
*/
|
|
3897
|
+
if (fields.shared !== undefined && typeof fields.shared !== 'boolean') {
|
|
3898
|
+
const problem = badRequest(instance, requestId, "'shared' must be a boolean.");
|
|
3899
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
const created = await options.users.create(auth, email, role, fields.shared === true);
|
|
3415
3903
|
if (created === undefined) {
|
|
3416
3904
|
await options.audit.write({
|
|
3417
3905
|
orgId: auth.orgId,
|