@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/adapters.d.ts.map +1 -1
- package/dist/adapters.js +2 -2
- package/dist/adapters.js.map +1 -1
- package/dist/main.js +60 -3
- package/dist/main.js.map +1 -1
- package/dist/oauth-store.d.ts +63 -0
- package/dist/oauth-store.d.ts.map +1 -0
- package/dist/oauth-store.js +107 -0
- package/dist/oauth-store.js.map +1 -0
- package/dist/rerank.d.ts.map +1 -1
- package/dist/rerank.js +2 -1
- package/dist/rerank.js.map +1 -1
- package/dist/server.d.ts +31 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +361 -1
- package/dist/server.js.map +1 -1
- package/dist/service-keys.d.ts +12 -0
- package/dist/service-keys.d.ts.map +1 -1
- package/dist/service-keys.js +11 -4
- package/dist/service-keys.js.map +1 -1
- package/package.json +2 -2
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
|