@nacre.work/api 0.19.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 +90 -4
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +133 -17
- package/dist/login.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/server.d.ts.map +1 -1
- package/dist/server.js +498 -228
- 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,6 +568,321 @@ 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
|
|
568
888
|
// exactly one exception: `/v1/auth/methods` is a **read**, and the contract
|
|
@@ -681,6 +1001,13 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
681
1001
|
}, requestId);
|
|
682
1002
|
return;
|
|
683
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
|
+
}
|
|
684
1011
|
const tokens = outcome.tokens;
|
|
685
1012
|
// The successful one does have an organization to belong to, and it is the
|
|
686
1013
|
// event that answers "who has been in here". Awaited: a lost audit event is
|
|
@@ -855,8 +1182,8 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
855
1182
|
}
|
|
856
1183
|
}
|
|
857
1184
|
}
|
|
858
|
-
const
|
|
859
|
-
if (
|
|
1185
|
+
const outcome = await options.login.completeSecondFactor(challenge, proof);
|
|
1186
|
+
if (outcome === undefined) {
|
|
860
1187
|
// One refusal for an expired challenge, a forged one, a wrong code and a
|
|
861
1188
|
// disabled account alike. Which of the four it was is nothing a client
|
|
862
1189
|
// needs and something an attacker would use.
|
|
@@ -864,6 +1191,14 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
864
1191
|
refuse();
|
|
865
1192
|
return;
|
|
866
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;
|
|
867
1202
|
await options.audit.write({
|
|
868
1203
|
orgId: tokens.orgId,
|
|
869
1204
|
actor: `user:${tokens.userId}`,
|
|
@@ -887,12 +1222,21 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
887
1222
|
return;
|
|
888
1223
|
}
|
|
889
1224
|
if (instance === '/v1/auth/refresh') {
|
|
890
|
-
const
|
|
891
|
-
if (
|
|
1225
|
+
const outcome = await options.login.refresh(token);
|
|
1226
|
+
if (outcome === undefined) {
|
|
892
1227
|
refuse();
|
|
893
1228
|
return;
|
|
894
1229
|
}
|
|
895
|
-
|
|
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);
|
|
896
1240
|
return;
|
|
897
1241
|
}
|
|
898
1242
|
if (instance === '/v1/auth/logout') {
|
|
@@ -1499,6 +1843,87 @@ async function handle(req, res, options) {
|
|
|
1499
1843
|
: presented.slice(7).startsWith('nacre_sk_')
|
|
1500
1844
|
? 'service_key'
|
|
1501
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
|
+
}
|
|
1502
1927
|
options.observe?.authFailures.inc({ kind });
|
|
1503
1928
|
send(res, auth.status, auth.toJSON(), requestId);
|
|
1504
1929
|
return;
|
|
@@ -2089,15 +2514,25 @@ async function handle(req, res, options) {
|
|
|
2089
2514
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2090
2515
|
return;
|
|
2091
2516
|
}
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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
|
+
*/
|
|
2096
2530
|
send(res, 200, {
|
|
2097
2531
|
organization: auth.orgId,
|
|
2098
2532
|
principal_type: auth.principal.type,
|
|
2099
2533
|
principal_id: auth.principal.id,
|
|
2100
2534
|
role: auth.role,
|
|
2535
|
+
holds_own_credentials: await holdsOwnCredentials(auth, options),
|
|
2101
2536
|
}, requestId);
|
|
2102
2537
|
return;
|
|
2103
2538
|
}
|
|
@@ -2122,7 +2557,7 @@ async function handle(req, res, options) {
|
|
|
2122
2557
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2123
2558
|
return;
|
|
2124
2559
|
}
|
|
2125
|
-
if (
|
|
2560
|
+
if (!(await holdsOwnCredentials(auth, options))) {
|
|
2126
2561
|
const problem = notFound(instance, requestId);
|
|
2127
2562
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2128
2563
|
return;
|
|
@@ -2179,6 +2614,31 @@ async function handle(req, res, options) {
|
|
|
2179
2614
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2180
2615
|
return;
|
|
2181
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
|
+
}
|
|
2182
2642
|
await options.audit.write({
|
|
2183
2643
|
orgId: auth.orgId,
|
|
2184
2644
|
actor: `user:${auth.principal.id}`,
|
|
@@ -2243,227 +2703,20 @@ async function handle(req, res, options) {
|
|
|
2243
2703
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2244
2704
|
return;
|
|
2245
2705
|
}
|
|
2246
|
-
if (
|
|
2706
|
+
if (!(await holdsOwnCredentials(auth, options))) {
|
|
2247
2707
|
const problem = notFound(instance, requestId);
|
|
2248
2708
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2249
2709
|
return;
|
|
2250
2710
|
}
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
items: items.map((f) => ({
|
|
2261
|
-
id: f.id,
|
|
2262
|
-
kind: f.kind,
|
|
2263
|
-
label: f.label,
|
|
2264
|
-
created_at: f.createdAt.toISOString(),
|
|
2265
|
-
last_used_at: f.lastUsedAt?.toISOString() ?? null,
|
|
2266
|
-
})),
|
|
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,
|
|
2273
|
-
}, requestId);
|
|
2274
|
-
return;
|
|
2275
|
-
}
|
|
2276
|
-
if (rest === '' && req.method === 'POST') {
|
|
2277
|
-
const label = body?.label;
|
|
2278
|
-
const named = typeof label === 'string' && label.trim() !== '' ? label.trim().slice(0, 60) : 'Authenticator';
|
|
2279
|
-
const begun = await factors.begin(auth.orgId, userId, named);
|
|
2280
|
-
if (begun === undefined) {
|
|
2281
|
-
const problem = notFound(instance, requestId);
|
|
2282
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
// The secret in the response and nowhere else: this is the one moment
|
|
2286
|
-
// it exists outside the sealed column, exactly as a generated password
|
|
2287
|
-
// is.
|
|
2288
|
-
send(res, 201, { id: begun.id, secret: begun.secret, otpauth_url: begun.otpauthUrl, label: named }, requestId);
|
|
2289
|
-
return;
|
|
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
|
-
}
|
|
2369
|
-
if (rest.endsWith('/confirm') && req.method === 'POST') {
|
|
2370
|
-
const id = rest.slice(1, -'/confirm'.length);
|
|
2371
|
-
const code = body?.code;
|
|
2372
|
-
if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
|
|
2373
|
-
const problem = badRequest(instance, requestId, "'code' is required.");
|
|
2374
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2375
|
-
return;
|
|
2376
|
-
}
|
|
2377
|
-
const codes = await factors.confirm(auth.orgId, userId, id, code);
|
|
2378
|
-
if (codes === undefined) {
|
|
2379
|
-
// One refusal for a wrong code and for an enrolment that is not
|
|
2380
|
-
// there. Telling them apart would say whether a given id exists.
|
|
2381
|
-
const problem = notFound(instance, requestId);
|
|
2382
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2383
|
-
return;
|
|
2384
|
-
}
|
|
2385
|
-
await options.audit.write({
|
|
2386
|
-
orgId: auth.orgId,
|
|
2387
|
-
actor: `user:${userId}`,
|
|
2388
|
-
action: 'second_factor.enrol',
|
|
2389
|
-
result: 'allow',
|
|
2390
|
-
target: { user_id: userId },
|
|
2391
|
-
detail: {},
|
|
2392
|
-
requestId,
|
|
2393
|
-
});
|
|
2394
|
-
// A notice, where the deployment can send one. The person who did not
|
|
2395
|
-
// do this is the one who needs to know, and a second factor appearing
|
|
2396
|
-
// on an account is exactly what somebody taking it over would do.
|
|
2397
|
-
// Dropped rather than raised: the enrolment happened.
|
|
2398
|
-
void notifySecurityChange(options, auth.orgId, userId, 'enrolled', requestId);
|
|
2399
|
-
// Printed once. A second call returns an empty list rather than new
|
|
2400
|
-
// codes, because reissuing them here would invalidate the set somebody
|
|
2401
|
-
// has already written down.
|
|
2402
|
-
send(res, 200, { recovery_codes: codes }, requestId);
|
|
2403
|
-
return;
|
|
2404
|
-
}
|
|
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') {
|
|
2420
|
-
const id = rest.slice(1);
|
|
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.");
|
|
2425
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2426
|
-
return;
|
|
2427
|
-
}
|
|
2428
|
-
/*
|
|
2429
|
-
* A current proof to take one off, and that is the whole reason this
|
|
2430
|
-
* endpoint takes a body at all. Removing the second factor is the first
|
|
2431
|
-
* thing somebody with a stolen session does, and a session is exactly
|
|
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.
|
|
2437
|
-
*/
|
|
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) {
|
|
2442
|
-
const problem = notFound(instance, requestId);
|
|
2443
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2444
|
-
return;
|
|
2445
|
-
}
|
|
2446
|
-
const removed = await factors.remove(auth.orgId, userId, id);
|
|
2447
|
-
if (!removed) {
|
|
2448
|
-
const problem = notFound(instance, requestId);
|
|
2449
|
-
send(res, problem.status, problem.toJSON(), requestId);
|
|
2450
|
-
return;
|
|
2451
|
-
}
|
|
2452
|
-
await options.audit.write({
|
|
2453
|
-
orgId: auth.orgId,
|
|
2454
|
-
actor: `user:${userId}`,
|
|
2455
|
-
action: 'second_factor.remove',
|
|
2456
|
-
result: 'allow',
|
|
2457
|
-
target: { user_id: userId },
|
|
2458
|
-
detail: {},
|
|
2459
|
-
requestId,
|
|
2460
|
-
});
|
|
2461
|
-
void notifySecurityChange(options, auth.orgId, userId, 'removed', requestId);
|
|
2462
|
-
send(res, 204, null, requestId);
|
|
2463
|
-
return;
|
|
2464
|
-
}
|
|
2465
|
-
const problem = notFound(instance, requestId);
|
|
2466
|
-
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
|
+
});
|
|
2467
2720
|
return;
|
|
2468
2721
|
}
|
|
2469
2722
|
if (instance === '/v1/embedding-providers' && options.embeddingProviders !== undefined) {
|
|
@@ -3629,7 +3882,24 @@ async function handle(req, res, options) {
|
|
|
3629
3882
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
3630
3883
|
return;
|
|
3631
3884
|
}
|
|
3632
|
-
|
|
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);
|
|
3633
3903
|
if (created === undefined) {
|
|
3634
3904
|
await options.audit.write({
|
|
3635
3905
|
orgId: auth.orgId,
|