@nacre.work/api 0.17.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -5,9 +5,10 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
5
5
  import { URL } from 'node:url';
6
6
  import { logger, MetadataError, MultipartError, multipartBoundary, parseMultipart, queryAudit, parseFilters, parseMetadata, PROTECTED_RESOURCE_PATH, JWKS_PATH, AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, CODE_TTL_MS, allowedRequestHeaders, corsHeaders, isPreflight, preflightHeaders, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, consentRedirect, generateClientId, generateCode, redirectAllowed, verifierMatches, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
7
7
  import { administers, administersTenants, authenticate, rejectTenantOverride, } from './auth.js';
8
- import { badRequest, internal, notAdministeredHere, notFound, Problem } from './errors.js';
8
+ import { badRequest, internal, notAdministeredHere, notFound, Problem, tooBusy } from './errors.js';
9
9
  import { isConflict, isReplay } from './idempotency.js';
10
10
  import { limitHeaders } from './limits.js';
11
+ import { MIN_PASSWORD_LENGTH } from './recovery.js';
11
12
  import { looksLikeEmail } from './principals.js';
12
13
  import { clientSource } from './source.js';
13
14
  import { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson, } from './audit-export.js';
@@ -487,8 +488,91 @@ const neverCached = (instance) => NEVER_CACHED.some((path) => instance === path
487
488
  * key, and an operator reading `KEYS nacre:rl:*` should not be reading a list
488
489
  * of who has an account here.
489
490
  */
491
+ /**
492
+ * Tell a person their second factor changed, where this deployment can.
493
+ *
494
+ * Best effort in every direction: no sender configured means no message, a
495
+ * relay that refuses is a log line, and neither stops the change that already
496
+ * happened. The address is read here rather than carried through the handler,
497
+ * because `AuthContext` holds a principal id and not an address.
498
+ *
499
+ * The two events are worth a message for the same reason a password change is:
500
+ * a factor appearing on an account, or disappearing from one, is what somebody
501
+ * taking it over does first.
502
+ */
503
+ async function notifySecurityChange(options, orgId, userId, what, requestId) {
504
+ const mailer = options.mailer;
505
+ if (mailer === undefined)
506
+ return;
507
+ try {
508
+ const address = await options.secondFactors?.emailOf(orgId, userId);
509
+ if (address === undefined || address === null)
510
+ return;
511
+ await mailer.send({
512
+ to: address,
513
+ subject: what === 'enrolled' ? 'A second factor was added to your Nacre account' : 'A second factor was removed from your Nacre account',
514
+ text: what === 'enrolled'
515
+ ? [
516
+ 'An authenticator was just added to this account.',
517
+ '',
518
+ 'If it was not you, somebody is signing in as you: change your password',
519
+ 'and tell your administrator.',
520
+ ].join('\n')
521
+ : [
522
+ 'An authenticator was just removed from this account.',
523
+ '',
524
+ 'Removing one is the first thing somebody with a stolen session does. If',
525
+ 'it was not you, change your password and tell your administrator.',
526
+ ].join('\n'),
527
+ });
528
+ }
529
+ catch (error) {
530
+ logger.warn('could not send a security notice', {
531
+ request_id: requestId,
532
+ error: String(error).slice(0, 200),
533
+ });
534
+ }
535
+ }
536
+ /**
537
+ * The one answer to a full password gate, at whichever boundary caught it.
538
+ *
539
+ * There are two, and the split is authentication: sign-in and password recovery
540
+ * are reached without a credential and run before the request path that has
541
+ * one, so a single `catch` cannot cover both. Both call this, so there is one
542
+ * status, one wording and one header however a hashing route was reached.
543
+ *
544
+ * It used to be caught beside `login()` alone, which made the claim in
545
+ * `core/passwords.ts` — "the caller answers 503" — true of sign-in and false of
546
+ * the three other routes that hash: creating a user, an administrator resetting
547
+ * somebody's password, and redeeming a recovery link each turned a loaded
548
+ * process into a `500`, which a client reports as a broken server and an
549
+ * operator investigates as a bug. A rule stated in a comment and held in one of
550
+ * four places is the shape this repository keeps closing.
551
+ *
552
+ * Logged as load rather than as a failure, and deliberately not written to the
553
+ * journal: an audit row saying a request failed is read as a defect, and
554
+ * shedding load is the design working.
555
+ *
556
+ * @returns whether it answered, so a caller reads as `if (handled) return`.
557
+ */
558
+ function handledTooBusy(res, error, instance, requestId) {
559
+ if (!(error instanceof TooBusy))
560
+ return false;
561
+ logger.warn('password gate full', { request_id: requestId, instance });
562
+ const problem = tooBusy(instance, requestId);
563
+ send(res, problem.status, problem.toJSON(), requestId, { 'retry-after': '2' });
564
+ return true;
565
+ }
490
566
  async function handleAuth(req, res, instance, requestId, options) {
491
- if (options.login === undefined || req.method !== 'POST') {
567
+ // Every route here produces a credential and therefore takes a body, with
568
+ // exactly one exception: `/v1/auth/methods` is a **read**, and the contract
569
+ // has said `get` since it was written. A blanket `!== 'POST'` refused it, so
570
+ // the endpoint whose whole job is telling a sign-in screen what exists
571
+ // answered 404 — and the console, reading `password_reset` off a problem
572
+ // document, hid the recovery link on every deployment including the ones
573
+ // with a relay configured. The feature did the opposite of its purpose.
574
+ const reads = instance === '/v1/auth/methods';
575
+ if (options.login === undefined || req.method !== (reads ? 'GET' : 'POST')) {
492
576
  const problem = notFound(instance, requestId);
493
577
  send(res, problem.status, problem.toJSON(), requestId);
494
578
  return;
@@ -557,41 +641,12 @@ async function handleAuth(req, res, instance, requestId, options) {
557
641
  return;
558
642
  }
559
643
  }
560
- let tokens;
561
- try {
562
- tokens = await options.login.login({
563
- email,
564
- password,
565
- ...(typeof organization === 'string' ? { organization } : {}),
566
- });
567
- }
568
- catch (error) {
569
- // The process is already verifying as many passwords as it will. scrypt
570
- // runs on libuv's thread pool, which is shared with DNS and file I/O, so
571
- // an unbounded login endpoint stops the *rest* of the API on a name
572
- // lookup — see the gate in core/passwords.ts.
573
- //
574
- // 503 and not 401: nothing was decided about these credentials, and
575
- // answering "not valid" to a request that was never checked is a lie the
576
- // client will act on. 503 with Retry-After is the honest one.
577
- //
578
- // Not an oracle either. It depends on how loaded the process is and not
579
- // at all on whether the account exists.
580
- if (error instanceof TooBusy) {
581
- const problem = new Problem({
582
- type: 'https://nacre.work/errors/unavailable',
583
- title: 'Service unavailable',
584
- status: 503,
585
- detail: 'Too many sign-in attempts are being processed. Try again shortly.',
586
- instance,
587
- requestId,
588
- });
589
- send(res, problem.status, problem.toJSON(), requestId, { 'retry-after': '2' });
590
- return;
591
- }
592
- throw error;
593
- }
594
- if (tokens === undefined) {
644
+ const outcome = await options.login.login({
645
+ email,
646
+ password,
647
+ ...(typeof organization === 'string' ? { organization } : {}),
648
+ });
649
+ if (outcome === undefined) {
595
650
  // Logged rather than audited, and the difference is not laziness. The
596
651
  // audit log is per-organization; an address that matches no user belongs
597
652
  // to no tenant, and giving that row an owner would put one
@@ -606,6 +661,27 @@ async function handleAuth(req, res, instance, requestId, options) {
606
661
  refuse();
607
662
  return;
608
663
  }
664
+ /*
665
+ * A correct password and a second factor to produce.
666
+ *
667
+ * Not an audit event yet, and not a `login` one whatever happens next: this
668
+ * is half of an authentication, and writing "login allow" here would put a
669
+ * successful sign-in in the journal for somebody who never produced their
670
+ * code. The event is written where the session is issued.
671
+ *
672
+ * 200 rather than 401, because nothing was refused — the client is being
673
+ * asked for the rest of what it needs, which is what `second_factor_required`
674
+ * says.
675
+ */
676
+ if (outcome.kind === 'second-factor') {
677
+ send(res, 200, {
678
+ second_factor_required: true,
679
+ challenge: outcome.challenge,
680
+ expires_in: outcome.expiresIn,
681
+ }, requestId);
682
+ return;
683
+ }
684
+ const tokens = outcome.tokens;
609
685
  // The successful one does have an organization to belong to, and it is the
610
686
  // event that answers "who has been in here". Awaited: a lost audit event is
611
687
  // worse than a slow response.
@@ -621,6 +697,189 @@ async function handleAuth(req, res, instance, requestId, options) {
621
697
  send(res, 200, tokenJson(tokens), requestId);
622
698
  return;
623
699
  }
700
+ /*
701
+ * What this installation offers before anybody has signed in.
702
+ *
703
+ * One boolean, and it exists so the console can leave the recovery link
704
+ * **off the screen** where no sender is configured rather than showing a
705
+ * control that answers 404. A screen offering what the server refuses is a
706
+ * defect this console has already shipped once.
707
+ *
708
+ * It tells a stranger that this deployment can send email, which is what they
709
+ * would learn by pressing the link anyway. It says nothing about any address.
710
+ */
711
+ if (instance === '/v1/auth/methods') {
712
+ send(res, 200, {
713
+ password_reset: options.recovery !== undefined,
714
+ // Which second factors this installation can *challenge* with. A
715
+ // sign-in screen that offered "use your security key" where the
716
+ // deployment has no relying party would be the same defect the
717
+ // recovery link was: a control the server refuses.
718
+ second_factor_kinds: options.secondFactors?.kinds ?? [],
719
+ }, requestId);
720
+ return;
721
+ }
722
+ /*
723
+ * Ask for a recovery link.
724
+ *
725
+ * **`204` whatever happened**, including for an address with no account, an
726
+ * address in two organizations, a disabled account and a send that failed.
727
+ * Anything else makes this the account-enumeration oracle the sign-in path is
728
+ * careful not to be — and this one needs no credential at all.
729
+ */
730
+ if (instance === '/v1/auth/password-reset' && options.recovery !== undefined) {
731
+ const email = (body ?? {});
732
+ if (typeof email.email !== 'string') {
733
+ const problem = badRequest(instance, requestId, "'email' is required.");
734
+ send(res, problem.status, problem.toJSON(), requestId);
735
+ return;
736
+ }
737
+ // The same two buckets sign-in has, and for the same reasons: the address
738
+ // one defends a mailbox from being flooded on somebody's behalf, and the
739
+ // source one bounds the sweep across a directory that never repeats an
740
+ // address.
741
+ if (options.limits !== undefined && options.limitPolicies?.login !== undefined) {
742
+ const subject = createHash('sha256').update(email.email.trim().toLowerCase()).digest('hex').slice(0, 32);
743
+ const source = clientSource(req, { trustProxy: options.trustProxy ?? 0 });
744
+ const decisions = [await options.limits.check(subject, 'login')];
745
+ if (source !== undefined && options.limitPolicies.login_source !== undefined) {
746
+ decisions.push(await options.limits.check(`src:${source}`, 'login_source'));
747
+ }
748
+ if (decisions.some((d) => !d.allowed)) {
749
+ // Still 204. A 429 here would say "this address is being asked about",
750
+ // which is the thing the endpoint refuses to say.
751
+ send(res, 204, null, requestId);
752
+ return;
753
+ }
754
+ }
755
+ // Awaited, but its failure is swallowed: the answer is the same either way,
756
+ // and an unhandled rejection would take the process down over a relay being
757
+ // briefly unreachable.
758
+ await options.recovery.request(email.email).catch((error) => {
759
+ logger.warn('could not start a password recovery', {
760
+ request_id: requestId,
761
+ error: String(error).slice(0, 200),
762
+ });
763
+ });
764
+ send(res, 204, null, requestId);
765
+ return;
766
+ }
767
+ if (instance === '/v1/auth/password-reset/confirm' && options.recovery !== undefined) {
768
+ const { token, password } = (body ?? {});
769
+ if (typeof token !== 'string' || typeof password !== 'string') {
770
+ const problem = badRequest(instance, requestId, "'token' and 'password' are required.");
771
+ send(res, problem.status, problem.toJSON(), requestId);
772
+ return;
773
+ }
774
+ const outcome = await options.recovery.redeem(token, password);
775
+ if (outcome === 'too-short') {
776
+ // The one thing this endpoint does say, because it is about what the
777
+ // caller sent rather than about what exists. A refusal with no reason
778
+ // here is a person retyping a password that will never be accepted.
779
+ const problem = badRequest(instance, requestId, `A password is at least ${String(MIN_PASSWORD_LENGTH)} characters. Length is the whole rule — there is no requirement about digits or symbols.`);
780
+ send(res, problem.status, problem.toJSON(), requestId);
781
+ return;
782
+ }
783
+ if (outcome === 'refused') {
784
+ // One answer for a link that never existed, one already used, one that
785
+ // expired, and an account disabled since it was sent.
786
+ refuse();
787
+ return;
788
+ }
789
+ send(res, 204, null, requestId);
790
+ return;
791
+ }
792
+ /*
793
+ * The second half of a sign-in.
794
+ *
795
+ * Rate limited on the same buckets as `/v1/auth/login`, because otherwise the
796
+ * limit is a limit on passwords and not on sessions: an attacker holding a
797
+ * password spends one login and then guesses six digits here without ever
798
+ * meeting a bucket again. The per-factor lock in Postgres is the bound that
799
+ * survives a Redis restart; this one is the bound that costs an attacker
800
+ * their source.
801
+ */
802
+ /*
803
+ * The options a browser needs before it can produce an assertion.
804
+ *
805
+ * It takes the sign-in challenge and nothing else, so this is not a route
806
+ * that will tell a stranger which authenticators an address holds: the
807
+ * challenge is a JWT this server signed one password ago. Not rate limited
808
+ * separately, because reaching it costs a correct password.
809
+ */
810
+ if (instance === '/v1/auth/second-factor/webauthn') {
811
+ const challenge = body?.challenge;
812
+ if (typeof challenge !== 'string') {
813
+ const problem = badRequest(instance, requestId, "'challenge' is required.");
814
+ send(res, problem.status, problem.toJSON(), requestId);
815
+ return;
816
+ }
817
+ const begun = await options.login.beginSecondFactorWebAuthn(challenge);
818
+ if (begun === undefined) {
819
+ // One refusal for a challenge that is not ours, one that has expired, and
820
+ // a person holding no key — the same rule the rest of this path follows.
821
+ refuse();
822
+ return;
823
+ }
824
+ send(res, 200, assertionOptionsJson(begun), requestId);
825
+ return;
826
+ }
827
+ if (instance === '/v1/auth/second-factor') {
828
+ const { challenge, code, assertion } = (body ?? {});
829
+ if (typeof challenge !== 'string') {
830
+ const problem = badRequest(instance, requestId, "'challenge' is required.");
831
+ send(res, problem.status, problem.toJSON(), requestId);
832
+ return;
833
+ }
834
+ const proof = readProof(code, assertion);
835
+ if (proof === undefined) {
836
+ const problem = badRequest(instance, requestId, "Either 'code' or 'assertion' is required.");
837
+ send(res, problem.status, problem.toJSON(), requestId);
838
+ return;
839
+ }
840
+ if (options.limits !== undefined && options.limitPolicies?.login !== undefined) {
841
+ const source = clientSource(req, { trustProxy: options.trustProxy ?? 0 });
842
+ if (source !== undefined && options.limitPolicies.login_source !== undefined) {
843
+ const decision = await options.limits.check(`src:${source}`, 'login_source');
844
+ if (!decision.allowed) {
845
+ const problem = new Problem({
846
+ type: 'https://nacre.work/errors/rate-limited',
847
+ title: 'Too many requests',
848
+ status: 429,
849
+ detail: `Too many sign-in attempts. Try again in ${decision.reset} seconds.`,
850
+ instance,
851
+ requestId,
852
+ });
853
+ send(res, problem.status, problem.toJSON(), requestId);
854
+ return;
855
+ }
856
+ }
857
+ }
858
+ const tokens = await options.login.completeSecondFactor(challenge, proof);
859
+ if (tokens === undefined) {
860
+ // One refusal for an expired challenge, a forged one, a wrong code and a
861
+ // disabled account alike. Which of the four it was is nothing a client
862
+ // needs and something an attacker would use.
863
+ logger.warn('second factor refused', { request_id: requestId });
864
+ refuse();
865
+ return;
866
+ }
867
+ await options.audit.write({
868
+ orgId: tokens.orgId,
869
+ actor: `user:${tokens.userId}`,
870
+ action: 'login',
871
+ result: 'allow',
872
+ target: { user_id: tokens.userId },
873
+ // The journal says which door, because "signed in with a second factor"
874
+ // and "signed in with a password" are different facts to an investigator
875
+ // — and so are the two kinds of second factor, since only one of them is
876
+ // proof against a page that looked like this one.
877
+ detail: { second_factor: true, second_factor_kind: proof.kind },
878
+ requestId,
879
+ });
880
+ send(res, 200, tokenJson(tokens), requestId);
881
+ return;
882
+ }
624
883
  const token = body?.refresh_token;
625
884
  if (typeof token !== 'string' || token === '') {
626
885
  const problem = badRequest(instance, requestId, "'refresh_token' is required.");
@@ -646,6 +905,71 @@ async function handleAuth(req, res, instance, requestId, options) {
646
905
  const problem = notFound(instance, requestId);
647
906
  send(res, problem.status, problem.toJSON(), requestId);
648
907
  }
908
+ /**
909
+ * A WebAuthn assertion out of a request body, or nothing.
910
+ *
911
+ * One parser, because two routes take one: the second half of a sign-in and
912
+ * taking a factor off an account. Two would be two ideas of which fields are
913
+ * required and which encoding they arrive in, and the half that got it wrong
914
+ * would refuse every authenticator with a message about neither.
915
+ *
916
+ * base64url throughout, which is what a browser reports and what
917
+ * `webauthn_challenges.challenge` stores — so the comparison downstream is a
918
+ * string equality and never an encoding round trip.
919
+ */
920
+ function readAssertion(value) {
921
+ if (typeof value !== 'object' || value === null)
922
+ return undefined;
923
+ const it = value;
924
+ const fields = ['credential_id', 'authenticator_data', 'client_data_json', 'signature', 'challenge'];
925
+ if (fields.some((f) => typeof it[f] !== 'string' || it[f] === ''))
926
+ return undefined;
927
+ const bytes = (name) => new Uint8Array(Buffer.from(it[name], 'base64url'));
928
+ return {
929
+ credentialId: it.credential_id,
930
+ authenticatorData: bytes('authenticator_data'),
931
+ clientDataJSON: bytes('client_data_json'),
932
+ signature: bytes('signature'),
933
+ challenge: it.challenge,
934
+ };
935
+ }
936
+ /**
937
+ * Which of the two proofs a body carries.
938
+ *
939
+ * Both present is a refusal rather than a precedence. A caller sending each is
940
+ * a caller with two ideas of how it is signing in, and resolving that by
941
+ * preferring one leaves the other apparently offered and ignored — which is
942
+ * the argument `loadConfig` makes about a secret and a key ref together.
943
+ */
944
+ function readProof(code, assertion) {
945
+ const hasCode = typeof code === 'string' && code !== '';
946
+ if (hasCode && assertion !== undefined)
947
+ return undefined;
948
+ if (hasCode)
949
+ return { kind: 'code', code: code };
950
+ const response = readAssertion(assertion);
951
+ return response === undefined ? undefined : { kind: 'webauthn', response };
952
+ }
953
+ /** The wire shape of what a browser needs for `navigator.credentials.get`. */
954
+ function assertionOptionsJson(options) {
955
+ return {
956
+ challenge: options.challenge,
957
+ rp_id: options.rpId,
958
+ allow_credentials: options.allowCredentials,
959
+ timeout_ms: options.timeoutMs,
960
+ };
961
+ }
962
+ /** And for `navigator.credentials.create`. */
963
+ function registrationOptionsJson(options) {
964
+ return {
965
+ challenge: options.challenge,
966
+ rp: { id: options.rp.id, name: options.rp.name },
967
+ user: { id: options.user.id, name: options.user.name, display_name: options.user.displayName },
968
+ algorithms: options.algorithms,
969
+ exclude_credentials: options.excludeCredentials,
970
+ timeout_ms: options.timeoutMs,
971
+ };
972
+ }
649
973
  function tokenJson(tokens) {
650
974
  return {
651
975
  access_token: tokens.accessToken,
@@ -849,7 +1173,22 @@ async function handle(req, res, options) {
849
1173
  // different way — see login.ts. The organization in the issued token comes
850
1174
  // from the row that authenticated, never from the request.
851
1175
  if (instance.startsWith('/v1/auth/')) {
852
- await handleAuth(req, res, instance, requestId, options);
1176
+ try {
1177
+ await handleAuth(req, res, instance, requestId, options);
1178
+ }
1179
+ catch (error) {
1180
+ // The second of this file's two error boundaries, and the split is
1181
+ // authentication: everything below this line has a credential and is
1182
+ // covered by the catch at the bottom of this function, while sign-in and
1183
+ // password recovery are reached without one and cannot be.
1184
+ //
1185
+ // Both defer to `handledTooBusy` rather than answering, so there is one
1186
+ // 503 and one wording however a hashing route was reached. Anything else
1187
+ // is rethrown to the handler `createApi` installs.
1188
+ if (handledTooBusy(res, error, instance, requestId))
1189
+ return;
1190
+ throw error;
1191
+ }
853
1192
  return;
854
1193
  }
855
1194
  // A path this server does not route is `404`, and it says so **before**
@@ -1762,6 +2101,371 @@ async function handle(req, res, options) {
1762
2101
  }, requestId);
1763
2102
  return;
1764
2103
  }
2104
+ /*
2105
+ * The caller's own password.
2106
+ *
2107
+ * Under `/v1/me` for the same reason the second factor is: this is a person
2108
+ * acting on themselves, and there is no id in the path for anybody to put
2109
+ * somebody else's in. `POST /v1/users/{id}/password` beside it is the other
2110
+ * thing — an administrator issuing a generated password to a colleague who
2111
+ * lost theirs — and the two are deliberately not one endpoint with a
2112
+ * branch.
2113
+ *
2114
+ * A service account is refused: a key has no password, and `nacre_sk_` is
2115
+ * rotated by minting another. A delegation is refused because changing how
2116
+ * somebody signs in is not something a third party acting for them was
2117
+ * approved to do — the same line the second factor draws.
2118
+ */
2119
+ if (instance === '/v1/me/password') {
2120
+ if (req.method !== 'POST' || options.login === undefined) {
2121
+ const problem = notFound(instance, requestId);
2122
+ send(res, problem.status, problem.toJSON(), requestId);
2123
+ return;
2124
+ }
2125
+ if (auth.principal.type !== 'user' || auth.delegation !== undefined) {
2126
+ const problem = notFound(instance, requestId);
2127
+ send(res, problem.status, problem.toJSON(), requestId);
2128
+ return;
2129
+ }
2130
+ const { current_password: currentPassword, new_password: newPassword } = (body ?? {});
2131
+ if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
2132
+ const problem = badRequest(instance, requestId, "'current_password' and 'new_password' are required.");
2133
+ send(res, problem.status, problem.toJSON(), requestId);
2134
+ return;
2135
+ }
2136
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
2137
+ const problem = badRequest(instance, requestId, `A password is at least ${String(MIN_PASSWORD_LENGTH)} characters. Length is the whole rule — there is no requirement about digits or symbols.`);
2138
+ send(res, problem.status, problem.toJSON(), requestId);
2139
+ return;
2140
+ }
2141
+ const outcome = await options.login.changePassword(auth.orgId, auth.principal.id, currentPassword, newPassword);
2142
+ if (typeof outcome === 'string') {
2143
+ await options.audit.write({
2144
+ orgId: auth.orgId,
2145
+ actor: `user:${auth.principal.id}`,
2146
+ action: 'password.change',
2147
+ result: 'deny',
2148
+ target: { user_id: auth.principal.id },
2149
+ detail: { reason: outcome },
2150
+ requestId,
2151
+ });
2152
+ /*
2153
+ * `403`, and the choice is about clients rather than about semantics.
2154
+ *
2155
+ * `401` is the obvious status for a credential that did not check out,
2156
+ * and it is the wrong one here: on an authenticated route `401` means
2157
+ * "your session is over", and every client in this repository renews on
2158
+ * it and replays. A wrong current password would therefore spend a
2159
+ * refresh token, replay, fail again, and reach the person as two
2160
+ * failures with a renewal between them — for something they can fix by
2161
+ * retyping.
2162
+ *
2163
+ * Not `404` either. The caller is looking straight at their own
2164
+ * account, so there is nothing invisible for invariant 4 to protect —
2165
+ * the same reasoning as the `platform_admin` refusal on `/v1/users`.
2166
+ *
2167
+ * One wording for a wrong password and for the guard cases beside it,
2168
+ * because the guard cases are unreachable by anyone holding a token
2169
+ * this row issued and telling them apart would say nothing true.
2170
+ */
2171
+ const problem = new Problem({
2172
+ type: 'https://nacre.work/errors/forbidden',
2173
+ title: 'Forbidden',
2174
+ status: 403,
2175
+ detail: 'The current password is not correct.',
2176
+ instance,
2177
+ requestId,
2178
+ });
2179
+ send(res, problem.status, problem.toJSON(), requestId);
2180
+ return;
2181
+ }
2182
+ await options.audit.write({
2183
+ orgId: auth.orgId,
2184
+ actor: `user:${auth.principal.id}`,
2185
+ action: 'password.change',
2186
+ result: 'allow',
2187
+ target: { user_id: auth.principal.id },
2188
+ detail: {},
2189
+ requestId,
2190
+ });
2191
+ // A notice, not a confirmation. Somebody who has taken an account changes
2192
+ // the password to keep it, so the person who did not do this is the one
2193
+ // who needs to know. Dropped rather than raised: the password *is*
2194
+ // changed, and refusing the request over a notice would be worse than a
2195
+ // notice that did not arrive.
2196
+ void options.mailer
2197
+ ?.send({
2198
+ to: outcome.email,
2199
+ subject: 'Your Nacre password was changed',
2200
+ text: [
2201
+ 'The password for this account was just changed from a signed-in session.',
2202
+ '',
2203
+ 'Every other session was signed out. Any second factor on the account is',
2204
+ 'untouched and is still required.',
2205
+ '',
2206
+ 'If this was not you, somebody knew your password — reset it from the',
2207
+ 'sign-in screen and tell your administrator.',
2208
+ ].join('\n'),
2209
+ })
2210
+ .catch(() => undefined);
2211
+ // The pair that replaces the sessions this just ended. Answering 204 and
2212
+ // leaving the caller holding a revoked refresh token would sign a person
2213
+ // out of the browser they changed their password in, which reads as a
2214
+ // failure.
2215
+ send(res, 200, {
2216
+ access_token: outcome.tokens.accessToken,
2217
+ token_type: 'Bearer',
2218
+ expires_in: outcome.tokens.expiresIn,
2219
+ refresh_token: outcome.tokens.refreshToken,
2220
+ }, requestId);
2221
+ return;
2222
+ }
2223
+ /*
2224
+ * The caller's own second factor.
2225
+ *
2226
+ * Under `/v1/me` and never `/v1/users/{id}`, which is the security property
2227
+ * rather than a URL preference: an administrator resets somebody's password
2228
+ * and must not be able to enrol, read or remove their second factor —
2229
+ * doing so would make the factor a thing the account's administrator holds,
2230
+ * and the whole point is that it is a thing the *person* holds.
2231
+ *
2232
+ * A service account and a delegation are refused. A key is not a person and
2233
+ * has nobody to carry an authenticator; a delegation is a third party
2234
+ * acting for somebody, and letting it change how that somebody signs in
2235
+ * would be an escalation out of what was approved.
2236
+ */
2237
+ if (instance === '/v1/me/second-factor' || instance.startsWith('/v1/me/second-factor/')) {
2238
+ if (options.secondFactors === undefined || !options.secondFactors.available) {
2239
+ // No key configured, so the feature is absent rather than broken. 404
2240
+ // and not 501: from outside, a route this installation does not serve
2241
+ // and one it has not been given a key for are the same thing.
2242
+ const problem = notFound(instance, requestId);
2243
+ send(res, problem.status, problem.toJSON(), requestId);
2244
+ return;
2245
+ }
2246
+ if (auth.principal.type !== 'user' || auth.delegation !== undefined) {
2247
+ const problem = notFound(instance, requestId);
2248
+ send(res, problem.status, problem.toJSON(), requestId);
2249
+ return;
2250
+ }
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);
2467
+ return;
2468
+ }
1765
2469
  if (instance === '/v1/embedding-providers' && options.embeddingProviders !== undefined) {
1766
2470
  if (req.method === 'GET') {
1767
2471
  // Not paged. A deployment has a handful of these — one per model it
@@ -3280,6 +3984,11 @@ async function handle(req, res, options) {
3280
3984
  send(res, problem.status, problem.toJSON(), requestId);
3281
3985
  }
3282
3986
  catch (error) {
3987
+ // Load, not a failure — and deliberately before the log and the journal
3988
+ // below, because an audit row saying a request failed is read as a defect
3989
+ // and shedding load is the design working.
3990
+ if (handledTooBusy(res, error, instance, requestId))
3991
+ return;
3283
3992
  // The 500 body says "whatever went wrong is in the journal under this
3284
3993
  // request_id", and until this line nothing wrote it there: the error was
3285
3994
  // discarded by a bare `catch`, and the audit row carried an empty detail.