@nacre.work/api 0.5.8 → 0.7.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/adapters.d.ts +1 -1
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +63 -5
- package/dist/adapters.js.map +1 -1
- package/dist/auth.d.ts +108 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +106 -2
- package/dist/auth.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/main.js +27 -10
- package/dist/main.js.map +1 -1
- package/dist/oauth-store.d.ts +107 -22
- package/dist/oauth-store.d.ts.map +1 -1
- package/dist/oauth-store.js +169 -26
- package/dist/oauth-store.js.map +1 -1
- package/dist/server.d.ts +9 -9
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +134 -26
- package/dist/server.js.map +1 -1
- package/dist/verification.d.ts +33 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +38 -0
- package/dist/verification.js.map +1 -0
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
4
4
|
// DOM, so the global URL is not typed here.
|
|
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, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, consentRedirect, generateClientId, generateCode, redirectAllowed, verifierMatches, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
|
|
7
|
-
import { authenticate, rejectTenantOverride } from './auth.js';
|
|
7
|
+
import { administers, administersTenants, authenticate, rejectTenantOverride, } from './auth.js';
|
|
8
8
|
import { badRequest, internal, notFound, Problem } from './errors.js';
|
|
9
9
|
import { isConflict, isReplay } from './idempotency.js';
|
|
10
10
|
import { limitHeaders } from './limits.js';
|
|
@@ -793,15 +793,17 @@ async function handle(req, res, options) {
|
|
|
793
793
|
// ─────────────────────── the authorization server ───────────────────────
|
|
794
794
|
//
|
|
795
795
|
// Three endpoints, and one decision running through all of them: the token
|
|
796
|
-
// this flow issues acts as
|
|
797
|
-
//
|
|
798
|
-
//
|
|
796
|
+
// this flow issues acts as whatever the *person* chose at consent — as them,
|
|
797
|
+
// or as an agent. Both are offered because they are different acts. A
|
|
798
|
+
// delegation reaches exactly what its person reaches and is recomputed every
|
|
799
|
+
// request; an agent is a principal with its own grants, and collapsing "what
|
|
800
|
+
// may this agent read" into "what may you read" would throw away the
|
|
799
801
|
// distinction this whole product is built on.
|
|
800
802
|
//
|
|
801
803
|
// Unauthenticated by necessity — a client arrives here with no credential,
|
|
802
804
|
// which is what it came to get. Authority is created at exactly one point:
|
|
803
805
|
// `POST /v1/oauth/consent`, which is inside the authenticated surface and is
|
|
804
|
-
// where a signed-in person
|
|
806
|
+
// where a signed-in person makes that choice.
|
|
805
807
|
if (options.oauth !== undefined && (instance === REGISTER_PATH || instance === AUTHORIZE_PATH || instance === TOKEN_PATH)) {
|
|
806
808
|
const oauth = options.oauth;
|
|
807
809
|
// RFC 7591. Open, which is what an MCP client expects and what the RFC is
|
|
@@ -1959,7 +1961,7 @@ async function handle(req, res, options) {
|
|
|
1959
1961
|
// about what a caller can tell apart — it is that a member must not
|
|
1960
1962
|
// reach module code at all, and "the module happened to have no matching
|
|
1961
1963
|
// route" is not a reason to be safe.
|
|
1962
|
-
if (auth
|
|
1964
|
+
if (!administersTenants(auth) && !administers(auth)) {
|
|
1963
1965
|
const problem = notFound(instance, requestId);
|
|
1964
1966
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
1965
1967
|
return;
|
|
@@ -2154,7 +2156,7 @@ async function handle(req, res, options) {
|
|
|
2154
2156
|
// build; it is for the multi-tenancy module, which inherits this endpoint
|
|
2155
2157
|
// and where a platform administrator spans tenants. Writing the rule now
|
|
2156
2158
|
// costs three lines. Retrofitting it later means auditing every caller.
|
|
2157
|
-
if (auth
|
|
2159
|
+
if (!administers(auth) && !administersTenants(auth)) {
|
|
2158
2160
|
const problem = notFound(instance, requestId);
|
|
2159
2161
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2160
2162
|
return;
|
|
@@ -2187,7 +2189,7 @@ async function handle(req, res, options) {
|
|
|
2187
2189
|
// `administrativeOnly` is set here and never read from the request. A
|
|
2188
2190
|
// caller cannot widen their own view by omitting a parameter, which is
|
|
2189
2191
|
// the shape this would take if it were a query filter.
|
|
2190
|
-
{ ...query, administrativeOnly: auth
|
|
2192
|
+
{ ...query, administrativeOnly: administersTenants(auth) }, page);
|
|
2191
2193
|
// Reading the log is itself an access worth recording. It is the one
|
|
2192
2194
|
// action where leaving it out is self-serving: an administrator who can
|
|
2193
2195
|
// read who-read-what without that read appearing is a hole in exactly the
|
|
@@ -2270,9 +2272,16 @@ async function handle(req, res, options) {
|
|
|
2270
2272
|
id: c.id,
|
|
2271
2273
|
client_id: c.clientId,
|
|
2272
2274
|
client_name: c.clientName,
|
|
2273
|
-
|
|
2275
|
+
// What it acts as, named rather than inferred from which of the
|
|
2276
|
+
// two id fields came back — the screen has to tell a person "this
|
|
2277
|
+
// application acts as you" from "this application acts as an
|
|
2278
|
+
// agent", and those are different sentences.
|
|
2279
|
+
acts_as: c.subject.actsAs,
|
|
2280
|
+
service_account_id: c.subject.actsAs === 'service_account' ? c.subject.serviceAccountId : null,
|
|
2274
2281
|
service_account_name: c.serviceAccountName,
|
|
2275
2282
|
approved_by: c.approvedBy,
|
|
2283
|
+
// Empty means the delegation reaches everything its approver does.
|
|
2284
|
+
layers: c.layers,
|
|
2276
2285
|
created_at: c.createdAt,
|
|
2277
2286
|
last_refreshed_at: c.lastRefreshedAt,
|
|
2278
2287
|
revoked_at: c.revokedAt,
|
|
@@ -2331,8 +2340,66 @@ async function handle(req, res, options) {
|
|
|
2331
2340
|
const redirectUri = need('redirect_uri');
|
|
2332
2341
|
const codeChallenge = need('code_challenge');
|
|
2333
2342
|
const serviceAccountId = need('service_account_id');
|
|
2334
|
-
if (clientId === undefined || redirectUri === undefined || codeChallenge === undefined
|
|
2335
|
-
const problem = badRequest(instance, requestId, "'client_id', 'redirect_uri'
|
|
2343
|
+
if (clientId === undefined || redirectUri === undefined || codeChallenge === undefined) {
|
|
2344
|
+
const problem = badRequest(instance, requestId, "'client_id', 'redirect_uri' and 'code_challenge' are required.");
|
|
2345
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
// Naming an agent is what makes this the agent flow; naming nobody is a
|
|
2349
|
+
// delegation. Not a mode flag beside the field, because two ways to say
|
|
2350
|
+
// one thing is two ways for them to disagree — and this endpoint used to
|
|
2351
|
+
// *require* the agent, which is exactly why a member reached a screen
|
|
2352
|
+
// they could not complete.
|
|
2353
|
+
const delegating = serviceAccountId === undefined;
|
|
2354
|
+
// Never delegable. It spans tenants in the multi-tenancy module, so a
|
|
2355
|
+
// delegation of it would be an escalation out of the organization this
|
|
2356
|
+
// screen is scoped to — the same argument that already refuses minting
|
|
2357
|
+
// one from an org-scoped endpoint. Refused here *and* again at
|
|
2358
|
+
// validation: this is the reachable path, the other is the one that holds
|
|
2359
|
+
// if a token is ever minted some other way.
|
|
2360
|
+
if (delegating && administersTenants(auth)) {
|
|
2361
|
+
const problem = notFound(instance, requestId);
|
|
2362
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
// The narrowing, which can only ever remove. Ids rather than slugs: this
|
|
2366
|
+
// is stored and read on the authentication path, and a slug is renameable
|
|
2367
|
+
// — a narrowing that follows a rename is one that silently changes what a
|
|
2368
|
+
// person approved.
|
|
2369
|
+
const narrowing = consent['layers'];
|
|
2370
|
+
if (narrowing !== undefined && !isStringArray(narrowing)) {
|
|
2371
|
+
const problem = badRequest(instance, requestId, "'layers' must be an array of layer ids.");
|
|
2372
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
// The permission ceiling, which is the dimension a person reaches for
|
|
2376
|
+
// first. Validated here rather than left to the database, so a typo is a
|
|
2377
|
+
// 400 naming the field instead of a constraint violation as a 500.
|
|
2378
|
+
const ceiling = consent['permissions'];
|
|
2379
|
+
if (ceiling !== undefined && (!isStringArray(ceiling) || ceiling.some((p) => !PERMISSIONS.includes(p)))) {
|
|
2380
|
+
const problem = badRequest(instance, requestId, "'permissions' must be an array of 'read', 'write' or 'admin'.");
|
|
2381
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
if (delegating && isStringArray(ceiling) && ceiling.length === 0) {
|
|
2385
|
+
// Empty is not "no ceiling", it is a delegation that can do nothing —
|
|
2386
|
+
// a restriction nobody meant to write, and the database refuses one
|
|
2387
|
+
// too. Omitting the field is how a caller says there is no ceiling.
|
|
2388
|
+
const problem = badRequest(instance, requestId, "'permissions' cannot be empty. Omit it for a delegation with no restriction.");
|
|
2389
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
if (!delegating && ceiling !== undefined) {
|
|
2393
|
+
// An agent's reach is its grants, which are an administrator's to set.
|
|
2394
|
+
const problem = badRequest(instance, requestId, "'permissions' restricts a delegation, and this consent names an agent.");
|
|
2395
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
if (!delegating && narrowing !== undefined && narrowing.length > 0) {
|
|
2399
|
+
// An agent's reach is its grants, and those are an administrator's to
|
|
2400
|
+
// set. Accepting a narrowing here and storing it against a connection
|
|
2401
|
+
// nothing reads it for would be a control that does nothing.
|
|
2402
|
+
const problem = badRequest(instance, requestId, "'layers' narrows a delegation, and this consent names an agent.");
|
|
2336
2403
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2337
2404
|
return;
|
|
2338
2405
|
}
|
|
@@ -2351,23 +2418,45 @@ async function handle(req, res, options) {
|
|
|
2351
2418
|
// consent theirs to give. `404` for anything else, because an agent they
|
|
2352
2419
|
// cannot see and one that does not exist are the same answer here as
|
|
2353
2420
|
// everywhere.
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2421
|
+
if (!delegating) {
|
|
2422
|
+
const visible = options.serviceAccounts === undefined
|
|
2423
|
+
? undefined
|
|
2424
|
+
: (await options.serviceAccounts.list(auth, { limit: 200, after: undefined })).items.find((a) => a.id === serviceAccountId);
|
|
2425
|
+
if (visible === undefined) {
|
|
2426
|
+
const problem = notFound(instance, requestId);
|
|
2427
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
// Every named layer has to be one this caller can read.
|
|
2432
|
+
//
|
|
2433
|
+
// Not a security check — a narrowing can only remove, so a layer they
|
|
2434
|
+
// cannot reach would simply contribute nothing. It is here because the
|
|
2435
|
+
// alternative is a foreign key violation surfacing as a 500 on a typo,
|
|
2436
|
+
// and because a person approving a restriction should be told when the
|
|
2437
|
+
// restriction they wrote is not the one they meant. `404` for an unknown
|
|
2438
|
+
// id and for an unreadable one alike, on invariant I6.
|
|
2439
|
+
if (delegating && narrowing !== undefined && narrowing.length > 0) {
|
|
2440
|
+
const readable = options.layers === undefined
|
|
2441
|
+
? []
|
|
2442
|
+
: (await options.layers.list(auth, { limit: 500, after: undefined })).items.map((l) => l.id);
|
|
2443
|
+
if (narrowing.some((id) => !readable.includes(id))) {
|
|
2444
|
+
const problem = notFound(instance, requestId);
|
|
2445
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2361
2448
|
}
|
|
2362
2449
|
// The standing connection first, then the code that points at it. That
|
|
2363
2450
|
// order matters: the code is exchanged for a refresh token hung on the
|
|
2364
2451
|
// connection, and a code with no connection would mint access nobody can
|
|
2365
2452
|
// ever take back — which is the gap this closes.
|
|
2366
|
-
const
|
|
2453
|
+
const subject = delegating
|
|
2454
|
+
? { actsAs: 'user', userId: auth.principal.id }
|
|
2455
|
+
: { actsAs: 'service_account', serviceAccountId: serviceAccountId };
|
|
2456
|
+
const consentId = await options.oauth.consents.record(auth, clientId, subject, narrowing === undefined ? [] : narrowing, isStringArray(ceiling) ? ceiling : undefined);
|
|
2367
2457
|
const code = generateCode();
|
|
2368
|
-
|
|
2458
|
+
const common = {
|
|
2369
2459
|
orgId: auth.orgId,
|
|
2370
|
-
serviceAccountId,
|
|
2371
2460
|
clientId,
|
|
2372
2461
|
redirectUri,
|
|
2373
2462
|
codeChallenge,
|
|
@@ -2375,7 +2464,13 @@ async function handle(req, res, options) {
|
|
|
2375
2464
|
code,
|
|
2376
2465
|
consentId,
|
|
2377
2466
|
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
|
2378
|
-
}
|
|
2467
|
+
};
|
|
2468
|
+
// Two calls rather than one with a union in it. The delegated shape
|
|
2469
|
+
// *requires* the connection id and the agent shape does not, and writing
|
|
2470
|
+
// that as one object hands the compiler a value it cannot place in either
|
|
2471
|
+
// arm — which is the same reason 0025 gave the database a CHECK per mode
|
|
2472
|
+
// instead of one that permits both halves.
|
|
2473
|
+
await options.oauth.authorizations.approve(auth, subject.actsAs === 'user' ? { ...common, subject } : { ...common, subject });
|
|
2379
2474
|
// Recorded, because "why does this agent hold a token" is a question an
|
|
2380
2475
|
// administrator will ask and the grant table cannot answer: the grants
|
|
2381
2476
|
// were there before, and what changed is that a client was handed the
|
|
@@ -2385,7 +2480,20 @@ async function handle(req, res, options) {
|
|
|
2385
2480
|
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2386
2481
|
action: 'oauth.consent',
|
|
2387
2482
|
result: 'allow',
|
|
2388
|
-
detail: {
|
|
2483
|
+
detail: {
|
|
2484
|
+
client_id: clientId,
|
|
2485
|
+
client_name: client.clientName,
|
|
2486
|
+
acts_as: subject.actsAs,
|
|
2487
|
+
...(delegating
|
|
2488
|
+
? {
|
|
2489
|
+
delegation_id: consentId,
|
|
2490
|
+
layers: narrowing ?? [],
|
|
2491
|
+
// Recorded because it is the answer to "why can this
|
|
2492
|
+
// application not delete anything", asked six months later.
|
|
2493
|
+
permissions: isStringArray(ceiling) ? ceiling : 'no ceiling',
|
|
2494
|
+
}
|
|
2495
|
+
: { service_account_id: serviceAccountId }),
|
|
2496
|
+
},
|
|
2389
2497
|
requestId,
|
|
2390
2498
|
});
|
|
2391
2499
|
// The redirect is returned rather than performed: this is an API call
|
|
@@ -2405,7 +2513,7 @@ async function handle(req, res, options) {
|
|
|
2405
2513
|
// in the organization rather than an object inside a workspace, and there
|
|
2406
2514
|
// is no scope to check it against — someone holding admin on one layer
|
|
2407
2515
|
// must not be able to mint credentials.
|
|
2408
|
-
if (auth
|
|
2516
|
+
if (!administers(auth)) {
|
|
2409
2517
|
const problem = notFound(instance, requestId);
|
|
2410
2518
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2411
2519
|
return;
|
|
@@ -2470,7 +2578,7 @@ async function handle(req, res, options) {
|
|
|
2470
2578
|
}
|
|
2471
2579
|
const accountMatch = /^\/v1\/service-accounts\/([^/]+)$/.exec(instance);
|
|
2472
2580
|
if (req.method === 'DELETE' && accountMatch && options.serviceAccounts !== undefined) {
|
|
2473
|
-
if (auth
|
|
2581
|
+
if (!administers(auth)) {
|
|
2474
2582
|
const problem = notFound(instance, requestId);
|
|
2475
2583
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
2476
2584
|
return;
|
|
@@ -2506,7 +2614,7 @@ async function handle(req, res, options) {
|
|
|
2506
2614
|
const principalPath = /^\/v1\/(users|groups)(\/.*)?$/.exec(instance);
|
|
2507
2615
|
if (principalPath !== null) {
|
|
2508
2616
|
const port = principalPath[1] === 'users' ? options.users : options.groups;
|
|
2509
|
-
if (port !== undefined && auth
|
|
2617
|
+
if (port !== undefined && !administers(auth)) {
|
|
2510
2618
|
await options.audit.write({
|
|
2511
2619
|
orgId: auth.orgId,
|
|
2512
2620
|
actor: `${auth.principal.type}:${auth.principal.id}`,
|