@nacre.work/api 0.5.1 → 0.5.3

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
@@ -3,7 +3,7 @@ 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, 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, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, generateClientId, generateCode, redirectAllowed, verifierMatches, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
7
7
  import { authenticate, rejectTenantOverride } from './auth.js';
8
8
  import { badRequest, internal, notFound, Problem } from './errors.js';
9
9
  import { isConflict, isReplay } from './idempotency.js';
@@ -54,6 +54,10 @@ function accountJson(a) {
54
54
  created_at: a.createdAt,
55
55
  last_used_at: a.lastUsedAt,
56
56
  revoked_at: a.revokedAt,
57
+ // Null on everything created before the column, and on anything `init`
58
+ // made. "My agents" filters on it; a guessed owner would be worse than
59
+ // none.
60
+ created_by: a.createdBy ?? null,
57
61
  };
58
62
  }
59
63
  function userJson(u) {
@@ -165,6 +169,15 @@ async function readRaw(req, limit) {
165
169
  }
166
170
  return Buffer.concat(chunks);
167
171
  }
172
+ /**
173
+ * The body as text, for the one endpoint whose media type is not JSON.
174
+ *
175
+ * RFC 6749 specifies `application/x-www-form-urlencoded` at the token endpoint,
176
+ * so it has to see the bytes rather than a parsed object.
177
+ */
178
+ async function readRawBody(req, limit = MAX_BODY_BYTES) {
179
+ return (await readRaw(req, limit)).toString('utf8');
180
+ }
168
181
  async function readBody(req, limit = MAX_BODY_BYTES) {
169
182
  const raw = await readRaw(req, limit);
170
183
  if (raw.length === 0)
@@ -678,6 +691,23 @@ async function handle(req, res, options) {
678
691
  send(res, 200, options.resourceMetadata, requestId);
679
692
  return;
680
693
  }
694
+ /**
695
+ * RFC 8414 — where a client finds the two endpoints.
696
+ *
697
+ * Served only when this deployment runs the authorization server. Absent, the
698
+ * discovery below still answers and simply names no `authorization_servers`,
699
+ * which is the resource-server-only shape this product had before and still
700
+ * supports: a deployment with its own identity provider names that instead.
701
+ */
702
+ if (req.method === 'GET' && instance === AUTHORIZATION_SERVER_PATH) {
703
+ if (options.oauth === undefined) {
704
+ const problem = notFound(instance, requestId);
705
+ send(res, problem.status, problem.toJSON(), requestId);
706
+ return;
707
+ }
708
+ send(res, 200, authorizationServerMetadata(options.oauth.issuer), requestId);
709
+ return;
710
+ }
681
711
  if (req.method === 'GET' && instance === JWKS_PATH) {
682
712
  // The public half of the signing key, so anything outside this process can
683
713
  // verify a token without a secret — a gateway, a sidecar, a second service
@@ -760,6 +790,192 @@ async function handle(req, res, options) {
760
790
  // rather than an omission: they are inside the authenticated surface, where
761
791
  // presenting a credential is the price of being told anything. Nothing is
762
792
  // concealed by it — every route this API serves is in docs/openapi.yaml.
793
+ // ─────────────────────── the authorization server ───────────────────────
794
+ //
795
+ // Three endpoints, and one decision running through all of them: the token
796
+ // this flow issues acts as a **service account**, never as the person who
797
+ // signed in. A consent screen that hands an agent your authority collapses
798
+ // "what may this agent read" into "what may you read", which is the
799
+ // distinction this whole product is built on.
800
+ //
801
+ // Unauthenticated by necessity — a client arrives here with no credential,
802
+ // which is what it came to get. Authority is created at exactly one point:
803
+ // `POST /v1/oauth/consent`, which is inside the authenticated surface and is
804
+ // where a signed-in person picks the agent.
805
+ if (options.oauth !== undefined && (instance === REGISTER_PATH || instance === AUTHORIZE_PATH || instance === TOKEN_PATH)) {
806
+ const oauth = options.oauth;
807
+ // RFC 7591. Open, which is what an MCP client expects and what the RFC is
808
+ // for; the exposure is bounded by the fact that a client row permits
809
+ // nothing at all. It becomes authority only when somebody signs in and
810
+ // approves it, and the consent screen shows the redirect URI beside the
811
+ // self-asserted name, because the URI is what actually decides where a code
812
+ // goes.
813
+ if (instance === REGISTER_PATH) {
814
+ if (req.method !== 'POST') {
815
+ const problem = notFound(instance, requestId);
816
+ send(res, problem.status, problem.toJSON(), requestId);
817
+ return;
818
+ }
819
+ // Read here rather than by the shared parse below: this endpoint sits
820
+ // ahead of the authenticated surface, which is where that happens.
821
+ let posted;
822
+ try {
823
+ posted = await readBody(req);
824
+ }
825
+ catch {
826
+ send(res, 400, { error: 'invalid_request', error_description: 'The request body could not be read.' }, requestId);
827
+ return;
828
+ }
829
+ const registration = (posted ?? {});
830
+ const uris = Array.isArray(registration.redirect_uris)
831
+ ? registration.redirect_uris.filter((u) => typeof u === 'string')
832
+ : [];
833
+ if (uris.length === 0) {
834
+ send(res, 400, { error: 'invalid_redirect_uri', error_description: "'redirect_uris' is required." }, requestId);
835
+ return;
836
+ }
837
+ // Checked at registration as well as at authorize. A URI that could never
838
+ // receive a code is better refused now, when there is somebody to tell,
839
+ // than at the redirect, when the failure is a blank browser tab.
840
+ if (!uris.every((u) => redirectAllowed(u, uris))) {
841
+ send(res, 400, {
842
+ error: 'invalid_redirect_uri',
843
+ error_description: 'Every redirect URI must be https, or http on loopback (127.0.0.1, [::1], localhost).',
844
+ }, requestId);
845
+ return;
846
+ }
847
+ const name = typeof registration.client_name === 'string' ? registration.client_name.slice(0, 200) : 'unnamed client';
848
+ const clientId = generateClientId();
849
+ await oauth.clients.register(name, uris, clientId);
850
+ // 201 and the RFC's field names. No secret: this is a public client and
851
+ // PKCE is what binds the exchange, so issuing one would be theatre.
852
+ send(res, 201, { client_id: clientId, client_name: name, redirect_uris: uris, token_endpoint_auth_method: 'none' }, requestId);
853
+ return;
854
+ }
855
+ // The authorize endpoint hands the browser to the consent screen and does
856
+ // nothing else. Nothing is written here: an unapproved authorization
857
+ // request is a set of query parameters the browser is already carrying, and
858
+ // storing it would add a table any unauthenticated caller could fill.
859
+ if (instance === AUTHORIZE_PATH) {
860
+ if (req.method !== 'GET') {
861
+ const problem = notFound(instance, requestId);
862
+ send(res, problem.status, problem.toJSON(), requestId);
863
+ return;
864
+ }
865
+ const q = url.searchParams;
866
+ const clientId = q.get('client_id') ?? '';
867
+ const redirectUri = q.get('redirect_uri') ?? '';
868
+ const client = clientId === '' ? undefined : await oauth.clients.find(clientId);
869
+ // These two are the only errors that must **not** redirect. RFC 6749 is
870
+ // explicit and the reason is worth stating: an unvalidated redirect URI
871
+ // is exactly what an attacker supplies, so bouncing an error to it would
872
+ // make this endpoint an open redirector.
873
+ if (client === undefined) {
874
+ send(res, 400, { error: 'invalid_client', error_description: 'Unknown client_id.' }, requestId);
875
+ return;
876
+ }
877
+ if (!redirectAllowed(redirectUri, client.redirectUris)) {
878
+ send(res, 400, { error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match a registered value.' }, requestId);
879
+ return;
880
+ }
881
+ const challenge = q.get('code_challenge') ?? '';
882
+ const method = q.get('code_challenge_method') ?? '';
883
+ const back = (error, description) => {
884
+ const to = new URL(redirectUri);
885
+ to.searchParams.set('error', error);
886
+ to.searchParams.set('error_description', description);
887
+ const state = q.get('state');
888
+ if (state !== null)
889
+ to.searchParams.set('state', state);
890
+ res.writeHead(302, { location: to.toString() });
891
+ res.end();
892
+ };
893
+ if (q.get('response_type') !== 'code') {
894
+ back('unsupported_response_type', 'Only the authorization code flow is supported.');
895
+ return;
896
+ }
897
+ // S256 only. `plain` is in RFC 7636 and defeats it — the verifier travels
898
+ // in the clear, so anybody holding the code holds the challenge too.
899
+ if (method !== 'S256' || challenge === '') {
900
+ back('invalid_request', 'PKCE with code_challenge_method=S256 is required.');
901
+ return;
902
+ }
903
+ // Straight to the consent screen, with the request carried in the
904
+ // fragment rather than the query: a fragment is not sent to a server, so
905
+ // the parameters do not end up in the admin origin's access log on the
906
+ // way past.
907
+ const consent = new URL(oauth.consentUrl);
908
+ consent.hash = q.toString();
909
+ res.writeHead(302, { location: consent.toString() });
910
+ res.end();
911
+ return;
912
+ }
913
+ // The exchange. A code, a verifier, and the redirect URI it was issued for.
914
+ if (instance === TOKEN_PATH) {
915
+ if (req.method !== 'POST') {
916
+ const problem = notFound(instance, requestId);
917
+ send(res, problem.status, problem.toJSON(), requestId);
918
+ return;
919
+ }
920
+ // `application/x-www-form-urlencoded` is what RFC 6749 specifies and what
921
+ // every client sends; JSON is accepted too because some send that and
922
+ // refusing it would be a conformance point nobody benefits from.
923
+ let form;
924
+ try {
925
+ const raw = await readRawBody(req);
926
+ form =
927
+ (req.headers['content-type'] ?? '').includes('json')
928
+ ? (JSON.parse(raw) ?? {})
929
+ : Object.fromEntries(new URLSearchParams(raw));
930
+ }
931
+ catch {
932
+ send(res, 400, { error: 'invalid_request', error_description: 'The request body could not be read.' }, requestId);
933
+ return;
934
+ }
935
+ const fail = (error, description) => {
936
+ send(res, 400, { error, error_description: description }, requestId);
937
+ };
938
+ if (form.grant_type !== 'authorization_code') {
939
+ fail('unsupported_grant_type', 'Only authorization_code is supported.');
940
+ return;
941
+ }
942
+ const code = typeof form.code === 'string' ? form.code : '';
943
+ const verifier = typeof form.code_verifier === 'string' ? form.code_verifier : '';
944
+ if (code === '' || verifier === '') {
945
+ fail('invalid_request', "'code' and 'code_verifier' are required.");
946
+ return;
947
+ }
948
+ // Consumed here whether or not the checks below pass, and deliberately:
949
+ // a code presented with a wrong verifier is a code that has been in the
950
+ // wrong hands, and the safe response is to spend it.
951
+ const approved = await oauth.authorizations.redeem(code);
952
+ if (approved === undefined) {
953
+ fail('invalid_grant', 'The code is unknown, expired, or already used.');
954
+ return;
955
+ }
956
+ if (!verifierMatches(verifier, approved.codeChallenge)) {
957
+ fail('invalid_grant', 'The code_verifier does not match the challenge.');
958
+ return;
959
+ }
960
+ if (typeof form.client_id === 'string' && form.client_id !== approved.clientId) {
961
+ fail('invalid_grant', 'The code was issued to another client.');
962
+ return;
963
+ }
964
+ if (typeof form.redirect_uri === 'string' && form.redirect_uri !== approved.redirectUri) {
965
+ fail('invalid_grant', 'The redirect_uri does not match the one the code was issued for.');
966
+ return;
967
+ }
968
+ const token = await oauth.mint(approved);
969
+ // `no-store`, which RFC 6749 requires of this response and which matters
970
+ // more here than usual: the body is a bearer token and a caching proxy
971
+ // that keeps it hands it to whoever asks next.
972
+ send(res, 200, { access_token: token.accessToken, token_type: 'Bearer', expires_in: token.expiresIn }, requestId, {
973
+ 'cache-control': 'no-store',
974
+ pragma: 'no-cache',
975
+ });
976
+ return;
977
+ }
978
+ }
763
979
  if (!instance.startsWith('/v1/')) {
764
980
  const problem = notFound(instance, requestId);
765
981
  send(res, problem.status, problem.toJSON(), requestId);
@@ -1343,6 +1559,47 @@ async function handle(req, res, options) {
1343
1559
  }, requestId);
1344
1560
  return;
1345
1561
  }
1562
+ /**
1563
+ * Who the caller is — and nothing about anyone else.
1564
+ *
1565
+ * There was no way to ask. The admin UI signed in, got a pair of tokens,
1566
+ * and had no idea whether the person holding them was an `org_admin` or a
1567
+ * `member` — so it drew every screen and every button for everybody. A
1568
+ * member then pressed "New user" and got a `404`, because invariant 4 makes
1569
+ * a refusal indistinguishable from a missing object. That is right for the
1570
+ * API and unusable as a product: to the person it reads as a broken
1571
+ * application rather than as a permission they do not hold.
1572
+ *
1573
+ * Telling a caller their own role is not a leak and is not in tension with
1574
+ * invariant 4. Rule 4 is about *objects* being invisible; this discloses
1575
+ * nothing except what the presented token already asserts, which the caller
1576
+ * necessarily has. Nothing here can name another principal.
1577
+ *
1578
+ * A service account gets an answer too, which the "sign in as an agent to
1579
+ * see what it sees" flow needs: the UI accepts a `nacre_sk_` key and could
1580
+ * not previously say which account it belonged to.
1581
+ */
1582
+ if (instance === '/v1/me') {
1583
+ // 404 for another method, the same as every other path here: invariant 4
1584
+ // reserves the distinction for objects, and a 405 on a path that answers
1585
+ // one verb tells a caller nothing they can act on.
1586
+ if (req.method !== 'GET') {
1587
+ const problem = notFound(instance, requestId);
1588
+ send(res, problem.status, problem.toJSON(), requestId);
1589
+ return;
1590
+ }
1591
+ // Composed from the token and nothing else — no query, so this cannot
1592
+ // grow a way to name somebody else. `organization` is included because a
1593
+ // UI showing "you are an administrator" has to say of what, and it is the
1594
+ // same value invariant 1 already took from the token.
1595
+ send(res, 200, {
1596
+ organization: auth.orgId,
1597
+ principal_type: auth.principal.type,
1598
+ principal_id: auth.principal.id,
1599
+ role: auth.role,
1600
+ }, requestId);
1601
+ return;
1602
+ }
1346
1603
  if (instance === '/v1/workspaces' && options.workspaces !== undefined) {
1347
1604
  if (req.method === 'GET') {
1348
1605
  const page = readPage(url.searchParams, instance, requestId);
@@ -1924,6 +2181,109 @@ async function handle(req, res, options) {
1924
2181
  res.end(format === 'ndjson' ? toNdjson(items) : toCsv(items));
1925
2182
  return;
1926
2183
  }
2184
+ /**
2185
+ * The one point in the OAuth flow where authority is created.
2186
+ *
2187
+ * Everything before it — registration, the authorize redirect — is a
2188
+ * conversation with an unauthenticated caller and grants nothing. This is
2189
+ * inside the authenticated surface because it has to be: a signed-in person
2190
+ * is choosing which **agent** the client will act as.
2191
+ *
2192
+ * The token that comes out acts as that service account and never as the
2193
+ * person. That is the design and not an implementation detail: an agent
2194
+ * holding your authority answers "what may this agent read" with "whatever
2195
+ * you may read", which is the question the whole permission model exists to
2196
+ * ask separately.
2197
+ *
2198
+ * The service account must already exist and the caller must be able to see
2199
+ * it. Creating one, and granting it anything, goes through the endpoints
2200
+ * that already exist and already check — this deliberately adds no second
2201
+ * path to either, because a second path is how the guarded one gets walked
2202
+ * around.
2203
+ */
2204
+ if (instance === '/v1/oauth/consent' && options.oauth !== undefined) {
2205
+ if (req.method !== 'POST') {
2206
+ const problem = notFound(instance, requestId);
2207
+ send(res, problem.status, problem.toJSON(), requestId);
2208
+ return;
2209
+ }
2210
+ // A service account cannot consent on behalf of anybody: the flow exists
2211
+ // so a *person* decides what an agent gets, and an agent approving its
2212
+ // own successor is that decision made by the thing it is about.
2213
+ if (auth.principal.type !== 'user') {
2214
+ const problem = notFound(instance, requestId);
2215
+ send(res, problem.status, problem.toJSON(), requestId);
2216
+ return;
2217
+ }
2218
+ const consent = (body ?? {});
2219
+ const need = (field) => typeof consent[field] === 'string' && consent[field] !== '' ? consent[field] : undefined;
2220
+ const clientId = need('client_id');
2221
+ const redirectUri = need('redirect_uri');
2222
+ const codeChallenge = need('code_challenge');
2223
+ const serviceAccountId = need('service_account_id');
2224
+ if (clientId === undefined || redirectUri === undefined || codeChallenge === undefined || serviceAccountId === undefined) {
2225
+ const problem = badRequest(instance, requestId, "'client_id', 'redirect_uri', 'code_challenge' and 'service_account_id' are required.");
2226
+ send(res, problem.status, problem.toJSON(), requestId);
2227
+ return;
2228
+ }
2229
+ // Re-checked here rather than trusted from the browser. Everything in
2230
+ // this body came back through a redirect the caller controls, so the
2231
+ // client and its redirect URI are verified against the registration
2232
+ // again — the authorize endpoint's check protected the redirect, and this
2233
+ // one protects the code.
2234
+ const client = await options.oauth.clients.find(clientId);
2235
+ if (client === undefined || !redirectAllowed(redirectUri, client.redirectUris)) {
2236
+ const problem = badRequest(instance, requestId, 'Unknown client, or a redirect_uri it did not register.');
2237
+ send(res, problem.status, problem.toJSON(), requestId);
2238
+ return;
2239
+ }
2240
+ // The agent has to be one this caller can see, which is what makes the
2241
+ // consent theirs to give. `404` for anything else, because an agent they
2242
+ // cannot see and one that does not exist are the same answer here as
2243
+ // everywhere.
2244
+ const visible = options.serviceAccounts === undefined
2245
+ ? undefined
2246
+ : (await options.serviceAccounts.list(auth, { limit: 200, after: undefined })).items.find((a) => a.id === serviceAccountId);
2247
+ if (visible === undefined) {
2248
+ const problem = notFound(instance, requestId);
2249
+ send(res, problem.status, problem.toJSON(), requestId);
2250
+ return;
2251
+ }
2252
+ const code = generateCode();
2253
+ await options.oauth.authorizations.approve(auth, {
2254
+ orgId: auth.orgId,
2255
+ serviceAccountId,
2256
+ clientId,
2257
+ redirectUri,
2258
+ codeChallenge,
2259
+ resource: need('resource'),
2260
+ code,
2261
+ expiresAt: new Date(Date.now() + CODE_TTL_MS),
2262
+ });
2263
+ // Recorded, because "why does this agent hold a token" is a question an
2264
+ // administrator will ask and the grant table cannot answer: the grants
2265
+ // were there before, and what changed is that a client was handed the
2266
+ // right to act as this principal.
2267
+ await options.audit?.write({
2268
+ orgId: auth.orgId,
2269
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2270
+ action: 'oauth.consent',
2271
+ result: 'allow',
2272
+ detail: { client_id: clientId, client_name: client.clientName, service_account_id: serviceAccountId },
2273
+ requestId,
2274
+ });
2275
+ // The redirect is returned rather than performed: this is an API call
2276
+ // from a page, and the page is what navigates. `state` is echoed
2277
+ // untouched — it is the client's, it is opaque to us, and it is how the
2278
+ // client ties the response back to the request it started.
2279
+ const to = new URL(redirectUri);
2280
+ to.searchParams.set('code', code);
2281
+ const state = need('state');
2282
+ if (state !== undefined)
2283
+ to.searchParams.set('state', state);
2284
+ send(res, 200, { redirect_to: to.toString() }, requestId);
2285
+ return;
2286
+ }
1927
2287
  if (instance === '/v1/service-accounts' && options.serviceAccounts !== undefined) {
1928
2288
  // org_admin, not "admin on some scope". A service account is a principal
1929
2289
  // in the organization rather than an object inside a workspace, and there