@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/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 tokens = await options.login.completeSecondFactor(challenge, proof);
859
- if (tokens === undefined) {
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 tokens = await options.login.refresh(token);
891
- if (tokens === undefined) {
1225
+ const outcome = await options.login.refresh(token);
1226
+ if (outcome === undefined) {
892
1227
  refuse();
893
1228
  return;
894
1229
  }
895
- send(res, 200, tokenJson(tokens), requestId);
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
- // Composed from the token and nothing else — no query, so this cannot
2093
- // grow a way to name somebody else. `organization` is included because a
2094
- // UI showing "you are an administrator" has to say of what, and it is the
2095
- // same value invariant 1 already took from the token.
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 (auth.principal.type !== 'user' || auth.delegation !== undefined) {
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 (auth.principal.type !== 'user' || auth.delegation !== undefined) {
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
- const factors = options.secondFactors;
2252
- const userId = auth.principal.id;
2253
- const rest = instance.slice('/v1/me/second-factor'.length);
2254
- if (rest === '' && req.method === 'GET') {
2255
- const [items, left] = await Promise.all([
2256
- factors.list(auth.orgId, userId),
2257
- factors.recoveryCodesLeft(auth.orgId, userId),
2258
- ]);
2259
- send(res, 200, {
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
- const created = await options.users.create(auth, email, role);
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,