@nacre.work/api 0.17.4 → 0.18.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/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +28 -0
- package/dist/errors.js.map +1 -1
- package/dist/login.d.ts +116 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +174 -3
- package/dist/login.js.map +1 -1
- package/dist/main.js +45 -1
- package/dist/main.js.map +1 -1
- package/dist/recovery.d.ts +91 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +199 -0
- package/dist/recovery.js.map +1 -0
- package/dist/second-factor.d.ts +89 -0
- package/dist/second-factor.d.ts.map +1 -0
- package/dist/second-factor.js +267 -0
- package/dist/second-factor.js.map +1 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +538 -42
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -3,11 +3,12 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
// Imported rather than taken from the global scope: `lib` is ES2023 with no
|
|
4
4
|
// DOM, so the global URL is not typed here.
|
|
5
5
|
import { URL } from 'node:url';
|
|
6
|
-
import { logger, MetadataError, MultipartError, multipartBoundary, parseMultipart, queryAudit, parseFilters, parseMetadata, PROTECTED_RESOURCE_PATH, JWKS_PATH, AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, CODE_TTL_MS, corsHeaders, isPreflight, preflightHeaders, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, consentRedirect, generateClientId, generateCode, redirectAllowed, verifierMatches, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
|
|
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,6 +488,81 @@ 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
567
|
if (options.login === undefined || req.method !== 'POST') {
|
|
492
568
|
const problem = notFound(instance, requestId);
|
|
@@ -557,41 +633,12 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
557
633
|
return;
|
|
558
634
|
}
|
|
559
635
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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) {
|
|
636
|
+
const outcome = await options.login.login({
|
|
637
|
+
email,
|
|
638
|
+
password,
|
|
639
|
+
...(typeof organization === 'string' ? { organization } : {}),
|
|
640
|
+
});
|
|
641
|
+
if (outcome === undefined) {
|
|
595
642
|
// Logged rather than audited, and the difference is not laziness. The
|
|
596
643
|
// audit log is per-organization; an address that matches no user belongs
|
|
597
644
|
// to no tenant, and giving that row an owner would put one
|
|
@@ -606,6 +653,27 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
606
653
|
refuse();
|
|
607
654
|
return;
|
|
608
655
|
}
|
|
656
|
+
/*
|
|
657
|
+
* A correct password and a second factor to produce.
|
|
658
|
+
*
|
|
659
|
+
* Not an audit event yet, and not a `login` one whatever happens next: this
|
|
660
|
+
* is half of an authentication, and writing "login allow" here would put a
|
|
661
|
+
* successful sign-in in the journal for somebody who never produced their
|
|
662
|
+
* code. The event is written where the session is issued.
|
|
663
|
+
*
|
|
664
|
+
* 200 rather than 401, because nothing was refused — the client is being
|
|
665
|
+
* asked for the rest of what it needs, which is what `second_factor_required`
|
|
666
|
+
* says.
|
|
667
|
+
*/
|
|
668
|
+
if (outcome.kind === 'second-factor') {
|
|
669
|
+
send(res, 200, {
|
|
670
|
+
second_factor_required: true,
|
|
671
|
+
challenge: outcome.challenge,
|
|
672
|
+
expires_in: outcome.expiresIn,
|
|
673
|
+
}, requestId);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const tokens = outcome.tokens;
|
|
609
677
|
// The successful one does have an organization to belong to, and it is the
|
|
610
678
|
// event that answers "who has been in here". Awaited: a lost audit event is
|
|
611
679
|
// worse than a slow response.
|
|
@@ -621,6 +689,149 @@ async function handleAuth(req, res, instance, requestId, options) {
|
|
|
621
689
|
send(res, 200, tokenJson(tokens), requestId);
|
|
622
690
|
return;
|
|
623
691
|
}
|
|
692
|
+
/*
|
|
693
|
+
* What this installation offers before anybody has signed in.
|
|
694
|
+
*
|
|
695
|
+
* One boolean, and it exists so the console can leave the recovery link
|
|
696
|
+
* **off the screen** where no sender is configured rather than showing a
|
|
697
|
+
* control that answers 404. A screen offering what the server refuses is a
|
|
698
|
+
* defect this console has already shipped once.
|
|
699
|
+
*
|
|
700
|
+
* It tells a stranger that this deployment can send email, which is what they
|
|
701
|
+
* would learn by pressing the link anyway. It says nothing about any address.
|
|
702
|
+
*/
|
|
703
|
+
if (instance === '/v1/auth/methods') {
|
|
704
|
+
send(res, 200, { password_reset: options.recovery !== undefined }, requestId);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
/*
|
|
708
|
+
* Ask for a recovery link.
|
|
709
|
+
*
|
|
710
|
+
* **`204` whatever happened**, including for an address with no account, an
|
|
711
|
+
* address in two organizations, a disabled account and a send that failed.
|
|
712
|
+
* Anything else makes this the account-enumeration oracle the sign-in path is
|
|
713
|
+
* careful not to be — and this one needs no credential at all.
|
|
714
|
+
*/
|
|
715
|
+
if (instance === '/v1/auth/password-reset' && options.recovery !== undefined) {
|
|
716
|
+
const email = (body ?? {});
|
|
717
|
+
if (typeof email.email !== 'string') {
|
|
718
|
+
const problem = badRequest(instance, requestId, "'email' is required.");
|
|
719
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// The same two buckets sign-in has, and for the same reasons: the address
|
|
723
|
+
// one defends a mailbox from being flooded on somebody's behalf, and the
|
|
724
|
+
// source one bounds the sweep across a directory that never repeats an
|
|
725
|
+
// address.
|
|
726
|
+
if (options.limits !== undefined && options.limitPolicies?.login !== undefined) {
|
|
727
|
+
const subject = createHash('sha256').update(email.email.trim().toLowerCase()).digest('hex').slice(0, 32);
|
|
728
|
+
const source = clientSource(req, { trustProxy: options.trustProxy ?? 0 });
|
|
729
|
+
const decisions = [await options.limits.check(subject, 'login')];
|
|
730
|
+
if (source !== undefined && options.limitPolicies.login_source !== undefined) {
|
|
731
|
+
decisions.push(await options.limits.check(`src:${source}`, 'login_source'));
|
|
732
|
+
}
|
|
733
|
+
if (decisions.some((d) => !d.allowed)) {
|
|
734
|
+
// Still 204. A 429 here would say "this address is being asked about",
|
|
735
|
+
// which is the thing the endpoint refuses to say.
|
|
736
|
+
send(res, 204, null, requestId);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Awaited, but its failure is swallowed: the answer is the same either way,
|
|
741
|
+
// and an unhandled rejection would take the process down over a relay being
|
|
742
|
+
// briefly unreachable.
|
|
743
|
+
await options.recovery.request(email.email).catch((error) => {
|
|
744
|
+
logger.warn('could not start a password recovery', {
|
|
745
|
+
request_id: requestId,
|
|
746
|
+
error: String(error).slice(0, 200),
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
send(res, 204, null, requestId);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (instance === '/v1/auth/password-reset/confirm' && options.recovery !== undefined) {
|
|
753
|
+
const { token, password } = (body ?? {});
|
|
754
|
+
if (typeof token !== 'string' || typeof password !== 'string') {
|
|
755
|
+
const problem = badRequest(instance, requestId, "'token' and 'password' are required.");
|
|
756
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const outcome = await options.recovery.redeem(token, password);
|
|
760
|
+
if (outcome === 'too-short') {
|
|
761
|
+
// The one thing this endpoint does say, because it is about what the
|
|
762
|
+
// caller sent rather than about what exists. A refusal with no reason
|
|
763
|
+
// here is a person retyping a password that will never be accepted.
|
|
764
|
+
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.`);
|
|
765
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (outcome === 'refused') {
|
|
769
|
+
// One answer for a link that never existed, one already used, one that
|
|
770
|
+
// expired, and an account disabled since it was sent.
|
|
771
|
+
refuse();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
send(res, 204, null, requestId);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
/*
|
|
778
|
+
* The second half of a sign-in.
|
|
779
|
+
*
|
|
780
|
+
* Rate limited on the same buckets as `/v1/auth/login`, because otherwise the
|
|
781
|
+
* limit is a limit on passwords and not on sessions: an attacker holding a
|
|
782
|
+
* password spends one login and then guesses six digits here without ever
|
|
783
|
+
* meeting a bucket again. The per-factor lock in Postgres is the bound that
|
|
784
|
+
* survives a Redis restart; this one is the bound that costs an attacker
|
|
785
|
+
* their source.
|
|
786
|
+
*/
|
|
787
|
+
if (instance === '/v1/auth/second-factor') {
|
|
788
|
+
const { challenge, code } = (body ?? {});
|
|
789
|
+
if (typeof challenge !== 'string' || typeof code !== 'string') {
|
|
790
|
+
const problem = badRequest(instance, requestId, "'challenge' and 'code' are required.");
|
|
791
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (options.limits !== undefined && options.limitPolicies?.login !== undefined) {
|
|
795
|
+
const source = clientSource(req, { trustProxy: options.trustProxy ?? 0 });
|
|
796
|
+
if (source !== undefined && options.limitPolicies.login_source !== undefined) {
|
|
797
|
+
const decision = await options.limits.check(`src:${source}`, 'login_source');
|
|
798
|
+
if (!decision.allowed) {
|
|
799
|
+
const problem = new Problem({
|
|
800
|
+
type: 'https://nacre.work/errors/rate-limited',
|
|
801
|
+
title: 'Too many requests',
|
|
802
|
+
status: 429,
|
|
803
|
+
detail: `Too many sign-in attempts. Try again in ${decision.reset} seconds.`,
|
|
804
|
+
instance,
|
|
805
|
+
requestId,
|
|
806
|
+
});
|
|
807
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const tokens = await options.login.completeSecondFactor(challenge, code);
|
|
813
|
+
if (tokens === undefined) {
|
|
814
|
+
// One refusal for an expired challenge, a forged one, a wrong code and a
|
|
815
|
+
// disabled account alike. Which of the four it was is nothing a client
|
|
816
|
+
// needs and something an attacker would use.
|
|
817
|
+
logger.warn('second factor refused', { request_id: requestId });
|
|
818
|
+
refuse();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
await options.audit.write({
|
|
822
|
+
orgId: tokens.orgId,
|
|
823
|
+
actor: `user:${tokens.userId}`,
|
|
824
|
+
action: 'login',
|
|
825
|
+
result: 'allow',
|
|
826
|
+
target: { user_id: tokens.userId },
|
|
827
|
+
// The journal says which door, because "signed in with a second factor"
|
|
828
|
+
// and "signed in with a password" are different facts to an investigator.
|
|
829
|
+
detail: { second_factor: true },
|
|
830
|
+
requestId,
|
|
831
|
+
});
|
|
832
|
+
send(res, 200, tokenJson(tokens), requestId);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
624
835
|
const token = body?.refresh_token;
|
|
625
836
|
if (typeof token !== 'string' || token === '') {
|
|
626
837
|
const problem = badRequest(instance, requestId, "'refresh_token' is required.");
|
|
@@ -705,10 +916,15 @@ async function handle(req, res, options) {
|
|
|
705
916
|
res.writeHead(204, preflightHeaders({
|
|
706
917
|
origin,
|
|
707
918
|
methods: 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
|
|
708
|
-
// What this API reads
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
919
|
+
// What this API reads *on top of* what every MCP client sends. That
|
|
920
|
+
// shared set is not optional here: this API serves the two `/.well-known`
|
|
921
|
+
// documents a browser MCP client reads, and the SDK puts
|
|
922
|
+
// `mcp-protocol-version` on those requests too.
|
|
923
|
+
//
|
|
924
|
+
// `idempotency-key` is the one the MCP transport has never heard of, and
|
|
925
|
+
// a browser that may not send it cannot make the retry-safe call the
|
|
926
|
+
// contract asks for.
|
|
927
|
+
headers: allowedRequestHeaders(['idempotency-key', 'if-none-match']),
|
|
712
928
|
}));
|
|
713
929
|
res.end();
|
|
714
930
|
return;
|
|
@@ -844,7 +1060,22 @@ async function handle(req, res, options) {
|
|
|
844
1060
|
// different way — see login.ts. The organization in the issued token comes
|
|
845
1061
|
// from the row that authenticated, never from the request.
|
|
846
1062
|
if (instance.startsWith('/v1/auth/')) {
|
|
847
|
-
|
|
1063
|
+
try {
|
|
1064
|
+
await handleAuth(req, res, instance, requestId, options);
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
// The second of this file's two error boundaries, and the split is
|
|
1068
|
+
// authentication: everything below this line has a credential and is
|
|
1069
|
+
// covered by the catch at the bottom of this function, while sign-in and
|
|
1070
|
+
// password recovery are reached without one and cannot be.
|
|
1071
|
+
//
|
|
1072
|
+
// Both defer to `handledTooBusy` rather than answering, so there is one
|
|
1073
|
+
// 503 and one wording however a hashing route was reached. Anything else
|
|
1074
|
+
// is rethrown to the handler `createApi` installs.
|
|
1075
|
+
if (handledTooBusy(res, error, instance, requestId))
|
|
1076
|
+
return;
|
|
1077
|
+
throw error;
|
|
1078
|
+
}
|
|
848
1079
|
return;
|
|
849
1080
|
}
|
|
850
1081
|
// A path this server does not route is `404`, and it says so **before**
|
|
@@ -1757,6 +1988,266 @@ async function handle(req, res, options) {
|
|
|
1757
1988
|
}, requestId);
|
|
1758
1989
|
return;
|
|
1759
1990
|
}
|
|
1991
|
+
/*
|
|
1992
|
+
* The caller's own password.
|
|
1993
|
+
*
|
|
1994
|
+
* Under `/v1/me` for the same reason the second factor is: this is a person
|
|
1995
|
+
* acting on themselves, and there is no id in the path for anybody to put
|
|
1996
|
+
* somebody else's in. `POST /v1/users/{id}/password` beside it is the other
|
|
1997
|
+
* thing — an administrator issuing a generated password to a colleague who
|
|
1998
|
+
* lost theirs — and the two are deliberately not one endpoint with a
|
|
1999
|
+
* branch.
|
|
2000
|
+
*
|
|
2001
|
+
* A service account is refused: a key has no password, and `nacre_sk_` is
|
|
2002
|
+
* rotated by minting another. A delegation is refused because changing how
|
|
2003
|
+
* somebody signs in is not something a third party acting for them was
|
|
2004
|
+
* approved to do — the same line the second factor draws.
|
|
2005
|
+
*/
|
|
2006
|
+
if (instance === '/v1/me/password') {
|
|
2007
|
+
if (req.method !== 'POST' || options.login === undefined) {
|
|
2008
|
+
const problem = notFound(instance, requestId);
|
|
2009
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
if (auth.principal.type !== 'user' || auth.delegation !== undefined) {
|
|
2013
|
+
const problem = notFound(instance, requestId);
|
|
2014
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
const { current_password: currentPassword, new_password: newPassword } = (body ?? {});
|
|
2018
|
+
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
|
2019
|
+
const problem = badRequest(instance, requestId, "'current_password' and 'new_password' are required.");
|
|
2020
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
2024
|
+
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.`);
|
|
2025
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const outcome = await options.login.changePassword(auth.orgId, auth.principal.id, currentPassword, newPassword);
|
|
2029
|
+
if (typeof outcome === 'string') {
|
|
2030
|
+
await options.audit.write({
|
|
2031
|
+
orgId: auth.orgId,
|
|
2032
|
+
actor: `user:${auth.principal.id}`,
|
|
2033
|
+
action: 'password.change',
|
|
2034
|
+
result: 'deny',
|
|
2035
|
+
target: { user_id: auth.principal.id },
|
|
2036
|
+
detail: { reason: outcome },
|
|
2037
|
+
requestId,
|
|
2038
|
+
});
|
|
2039
|
+
/*
|
|
2040
|
+
* `403`, and the choice is about clients rather than about semantics.
|
|
2041
|
+
*
|
|
2042
|
+
* `401` is the obvious status for a credential that did not check out,
|
|
2043
|
+
* and it is the wrong one here: on an authenticated route `401` means
|
|
2044
|
+
* "your session is over", and every client in this repository renews on
|
|
2045
|
+
* it and replays. A wrong current password would therefore spend a
|
|
2046
|
+
* refresh token, replay, fail again, and reach the person as two
|
|
2047
|
+
* failures with a renewal between them — for something they can fix by
|
|
2048
|
+
* retyping.
|
|
2049
|
+
*
|
|
2050
|
+
* Not `404` either. The caller is looking straight at their own
|
|
2051
|
+
* account, so there is nothing invisible for invariant 4 to protect —
|
|
2052
|
+
* the same reasoning as the `platform_admin` refusal on `/v1/users`.
|
|
2053
|
+
*
|
|
2054
|
+
* One wording for a wrong password and for the guard cases beside it,
|
|
2055
|
+
* because the guard cases are unreachable by anyone holding a token
|
|
2056
|
+
* this row issued and telling them apart would say nothing true.
|
|
2057
|
+
*/
|
|
2058
|
+
const problem = new Problem({
|
|
2059
|
+
type: 'https://nacre.work/errors/forbidden',
|
|
2060
|
+
title: 'Forbidden',
|
|
2061
|
+
status: 403,
|
|
2062
|
+
detail: 'The current password is not correct.',
|
|
2063
|
+
instance,
|
|
2064
|
+
requestId,
|
|
2065
|
+
});
|
|
2066
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
await options.audit.write({
|
|
2070
|
+
orgId: auth.orgId,
|
|
2071
|
+
actor: `user:${auth.principal.id}`,
|
|
2072
|
+
action: 'password.change',
|
|
2073
|
+
result: 'allow',
|
|
2074
|
+
target: { user_id: auth.principal.id },
|
|
2075
|
+
detail: {},
|
|
2076
|
+
requestId,
|
|
2077
|
+
});
|
|
2078
|
+
// A notice, not a confirmation. Somebody who has taken an account changes
|
|
2079
|
+
// the password to keep it, so the person who did not do this is the one
|
|
2080
|
+
// who needs to know. Dropped rather than raised: the password *is*
|
|
2081
|
+
// changed, and refusing the request over a notice would be worse than a
|
|
2082
|
+
// notice that did not arrive.
|
|
2083
|
+
void options.mailer
|
|
2084
|
+
?.send({
|
|
2085
|
+
to: outcome.email,
|
|
2086
|
+
subject: 'Your Nacre password was changed',
|
|
2087
|
+
text: [
|
|
2088
|
+
'The password for this account was just changed from a signed-in session.',
|
|
2089
|
+
'',
|
|
2090
|
+
'Every other session was signed out. Any second factor on the account is',
|
|
2091
|
+
'untouched and is still required.',
|
|
2092
|
+
'',
|
|
2093
|
+
'If this was not you, somebody knew your password — reset it from the',
|
|
2094
|
+
'sign-in screen and tell your administrator.',
|
|
2095
|
+
].join('\n'),
|
|
2096
|
+
})
|
|
2097
|
+
.catch(() => undefined);
|
|
2098
|
+
// The pair that replaces the sessions this just ended. Answering 204 and
|
|
2099
|
+
// leaving the caller holding a revoked refresh token would sign a person
|
|
2100
|
+
// out of the browser they changed their password in, which reads as a
|
|
2101
|
+
// failure.
|
|
2102
|
+
send(res, 200, {
|
|
2103
|
+
access_token: outcome.tokens.accessToken,
|
|
2104
|
+
token_type: 'Bearer',
|
|
2105
|
+
expires_in: outcome.tokens.expiresIn,
|
|
2106
|
+
refresh_token: outcome.tokens.refreshToken,
|
|
2107
|
+
}, requestId);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
/*
|
|
2111
|
+
* The caller's own second factor.
|
|
2112
|
+
*
|
|
2113
|
+
* Under `/v1/me` and never `/v1/users/{id}`, which is the security property
|
|
2114
|
+
* rather than a URL preference: an administrator resets somebody's password
|
|
2115
|
+
* and must not be able to enrol, read or remove their second factor —
|
|
2116
|
+
* doing so would make the factor a thing the account's administrator holds,
|
|
2117
|
+
* and the whole point is that it is a thing the *person* holds.
|
|
2118
|
+
*
|
|
2119
|
+
* A service account and a delegation are refused. A key is not a person and
|
|
2120
|
+
* has nobody to carry an authenticator; a delegation is a third party
|
|
2121
|
+
* acting for somebody, and letting it change how that somebody signs in
|
|
2122
|
+
* would be an escalation out of what was approved.
|
|
2123
|
+
*/
|
|
2124
|
+
if (instance === '/v1/me/second-factor' || instance.startsWith('/v1/me/second-factor/')) {
|
|
2125
|
+
if (options.secondFactors === undefined || !options.secondFactors.available) {
|
|
2126
|
+
// No key configured, so the feature is absent rather than broken. 404
|
|
2127
|
+
// and not 501: from outside, a route this installation does not serve
|
|
2128
|
+
// and one it has not been given a key for are the same thing.
|
|
2129
|
+
const problem = notFound(instance, requestId);
|
|
2130
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
if (auth.principal.type !== 'user' || auth.delegation !== undefined) {
|
|
2134
|
+
const problem = notFound(instance, requestId);
|
|
2135
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
const factors = options.secondFactors;
|
|
2139
|
+
const userId = auth.principal.id;
|
|
2140
|
+
const rest = instance.slice('/v1/me/second-factor'.length);
|
|
2141
|
+
if (rest === '' && req.method === 'GET') {
|
|
2142
|
+
const [items, left] = await Promise.all([
|
|
2143
|
+
factors.list(auth.orgId, userId),
|
|
2144
|
+
factors.recoveryCodesLeft(auth.orgId, userId),
|
|
2145
|
+
]);
|
|
2146
|
+
send(res, 200, {
|
|
2147
|
+
items: items.map((f) => ({
|
|
2148
|
+
id: f.id,
|
|
2149
|
+
kind: f.kind,
|
|
2150
|
+
label: f.label,
|
|
2151
|
+
created_at: f.createdAt.toISOString(),
|
|
2152
|
+
last_used_at: f.lastUsedAt?.toISOString() ?? null,
|
|
2153
|
+
})),
|
|
2154
|
+
recovery_codes_left: left,
|
|
2155
|
+
}, requestId);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (rest === '' && req.method === 'POST') {
|
|
2159
|
+
const label = body?.label;
|
|
2160
|
+
const named = typeof label === 'string' && label.trim() !== '' ? label.trim().slice(0, 60) : 'Authenticator';
|
|
2161
|
+
const begun = await factors.begin(auth.orgId, userId, named);
|
|
2162
|
+
if (begun === undefined) {
|
|
2163
|
+
const problem = notFound(instance, requestId);
|
|
2164
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
// The secret in the response and nowhere else: this is the one moment
|
|
2168
|
+
// it exists outside the sealed column, exactly as a generated password
|
|
2169
|
+
// is.
|
|
2170
|
+
send(res, 201, { id: begun.id, secret: begun.secret, otpauth_url: begun.otpauthUrl, label: named }, requestId);
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (rest.endsWith('/confirm') && req.method === 'POST') {
|
|
2174
|
+
const id = rest.slice(1, -'/confirm'.length);
|
|
2175
|
+
const code = body?.code;
|
|
2176
|
+
if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
|
|
2177
|
+
const problem = badRequest(instance, requestId, "'code' is required.");
|
|
2178
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const codes = await factors.confirm(auth.orgId, userId, id, code);
|
|
2182
|
+
if (codes === undefined) {
|
|
2183
|
+
// One refusal for a wrong code and for an enrolment that is not
|
|
2184
|
+
// there. Telling them apart would say whether a given id exists.
|
|
2185
|
+
const problem = notFound(instance, requestId);
|
|
2186
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
await options.audit.write({
|
|
2190
|
+
orgId: auth.orgId,
|
|
2191
|
+
actor: `user:${userId}`,
|
|
2192
|
+
action: 'second_factor.enrol',
|
|
2193
|
+
result: 'allow',
|
|
2194
|
+
target: { user_id: userId },
|
|
2195
|
+
detail: {},
|
|
2196
|
+
requestId,
|
|
2197
|
+
});
|
|
2198
|
+
// A notice, where the deployment can send one. The person who did not
|
|
2199
|
+
// do this is the one who needs to know, and a second factor appearing
|
|
2200
|
+
// on an account is exactly what somebody taking it over would do.
|
|
2201
|
+
// Dropped rather than raised: the enrolment happened.
|
|
2202
|
+
void notifySecurityChange(options, auth.orgId, userId, 'enrolled', requestId);
|
|
2203
|
+
// Printed once. A second call returns an empty list rather than new
|
|
2204
|
+
// codes, because reissuing them here would invalidate the set somebody
|
|
2205
|
+
// has already written down.
|
|
2206
|
+
send(res, 200, { recovery_codes: codes }, requestId);
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
if (rest !== '' && !rest.includes('/') && req.method === 'DELETE') {
|
|
2210
|
+
const id = rest.slice(1);
|
|
2211
|
+
const code = body?.code;
|
|
2212
|
+
if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
|
|
2213
|
+
const problem = badRequest(instance, requestId, "'code' is required to remove a second factor.");
|
|
2214
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
/*
|
|
2218
|
+
* A current code to take one off, and that is the whole reason this
|
|
2219
|
+
* endpoint takes a body at all. Removing the second factor is the first
|
|
2220
|
+
* thing somebody with a stolen session does, and a session is exactly
|
|
2221
|
+
* what the factor exists to be more than.
|
|
2222
|
+
*/
|
|
2223
|
+
if (!(await factors.verify(auth.orgId, userId, code))) {
|
|
2224
|
+
const problem = notFound(instance, requestId);
|
|
2225
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
const removed = await factors.remove(auth.orgId, userId, id);
|
|
2229
|
+
if (!removed) {
|
|
2230
|
+
const problem = notFound(instance, requestId);
|
|
2231
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
await options.audit.write({
|
|
2235
|
+
orgId: auth.orgId,
|
|
2236
|
+
actor: `user:${userId}`,
|
|
2237
|
+
action: 'second_factor.remove',
|
|
2238
|
+
result: 'allow',
|
|
2239
|
+
target: { user_id: userId },
|
|
2240
|
+
detail: {},
|
|
2241
|
+
requestId,
|
|
2242
|
+
});
|
|
2243
|
+
void notifySecurityChange(options, auth.orgId, userId, 'removed', requestId);
|
|
2244
|
+
send(res, 204, null, requestId);
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const problem = notFound(instance, requestId);
|
|
2248
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
1760
2251
|
if (instance === '/v1/embedding-providers' && options.embeddingProviders !== undefined) {
|
|
1761
2252
|
if (req.method === 'GET') {
|
|
1762
2253
|
// Not paged. A deployment has a handful of these — one per model it
|
|
@@ -3275,6 +3766,11 @@ async function handle(req, res, options) {
|
|
|
3275
3766
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
3276
3767
|
}
|
|
3277
3768
|
catch (error) {
|
|
3769
|
+
// Load, not a failure — and deliberately before the log and the journal
|
|
3770
|
+
// below, because an audit row saying a request failed is read as a defect
|
|
3771
|
+
// and shedding load is the design working.
|
|
3772
|
+
if (handledTooBusy(res, error, instance, requestId))
|
|
3773
|
+
return;
|
|
3278
3774
|
// The 500 body says "whatever went wrong is in the journal under this
|
|
3279
3775
|
// request_id", and until this line nothing wrote it there: the error was
|
|
3280
3776
|
// discarded by a bare `catch`, and the audit row carried an empty detail.
|