@anchrd/intel-api 0.6.0 → 0.6.2

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.
@@ -59,11 +59,25 @@ export function createOpenId(deps) {
59
59
  ...(insecure ? { execute: [client.allowInsecureRequests] } : {}),
60
60
  };
61
61
  }
62
+ // openid-client resolves issuer metadata via OIDC discovery unless told otherwise. Gate serves
63
+ // that document, but a plain OAuth 2.0 authorization server — the MCP portal on its custom
64
+ // domain is one — publishes only RFC 8414's oauth-authorization-server path and answers the OIDC
65
+ // one with a 404, which surfaced as a bare 500 on /auth/connect (#93). The fallback repeats the
66
+ // operation once with the OAuth document; when both fail, the second error is thrown because it
67
+ // belongs to the attempt that got further for the issuer that needed the fallback at all.
68
+ async function withAlgorithmFallback(run) {
69
+ try {
70
+ return await run();
71
+ }
72
+ catch {
73
+ return await run("oauth2");
74
+ }
75
+ }
62
76
  async function discover(issuer, clientId) {
63
77
  const key = `${issuer}#${clientId}`;
64
78
  let configuration = configurations.get(key);
65
79
  if (!configuration) {
66
- configuration = client.discovery(new URL(issuer), clientId, { token_endpoint_auth_method: "none" }, client.None(), options(issuer));
80
+ configuration = withAlgorithmFallback((algorithm) => client.discovery(new URL(issuer), clientId, { token_endpoint_auth_method: "none" }, client.None(), { ...options(issuer), ...(algorithm ? { algorithm } : {}) }));
67
81
  configurations.set(key, configuration);
68
82
  void configuration.catch(() => configurations.delete(key));
69
83
  }
@@ -118,7 +132,7 @@ export function createOpenId(deps) {
118
132
  throw new Error("MCP resource did not publish valid OAuth metadata");
119
133
  },
120
134
  async register(input) {
121
- const configuration = await client.dynamicClientRegistration(new URL(input.issuer), {
135
+ const configuration = await withAlgorithmFallback((algorithm) => client.dynamicClientRegistration(new URL(input.issuer), {
122
136
  client_name: input.clientName,
123
137
  redirect_uris: [input.redirectUri],
124
138
  response_types: ["code"],
@@ -128,7 +142,7 @@ export function createOpenId(deps) {
128
142
  : { scope: "openid profile email offline_access" }),
129
143
  token_endpoint_auth_method: "none",
130
144
  ...(isCloudflareAccess(input.issuer) ? { resource: input.resource } : {}),
131
- }, client.None(), options(input.issuer));
145
+ }, client.None(), { ...options(input.issuer), ...(algorithm ? { algorithm } : {}) }));
132
146
  return configuration.clientMetadata().client_id;
133
147
  },
134
148
  async authorizationUrl(input) {
@@ -30,6 +30,9 @@ const ConnectionPendingSession = z.strictObject({
30
30
  // Older cookies carry no `silent`, and one in flight across a deploy must not become an invalid
31
31
  // session: absent means the visible flow, which is what those attempts were.
32
32
  silent: z.boolean().default(false),
33
+ // Same for `resumed`: absent means a first attempt, so a handoff written before the deploy still
34
+ // gets its one restart rather than dying at the parser.
35
+ resumed: z.boolean().default(false),
33
36
  expiresAt: z.number().int().positive(),
34
37
  });
35
38
  const Session = z.discriminatedUnion("kind", [
package/dist/auth/auth.js CHANGED
@@ -1,7 +1,16 @@
1
1
  import { calculatePKCECodeChallenge, randomPKCECodeVerifier, randomState } from "openid-client";
2
2
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
+ import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
3
4
  import { SafeReturnPath } from "../shared/safe-return-path/safe-return-path.js";
4
5
  const cookieName = "intel_session";
6
+ // ⚠️ The cookie outlives the handoff on purpose. Ten minutes is right for something carrying a PKCE
7
+ // verifier, but the portal's consent screen can stand open longer — it waits for every server to
8
+ // connect, and a person steps away meanwhile. An expired handoff that is still *readable* tells the
9
+ // callback what was being attempted, where the person wanted to go, and whether their Intel session
10
+ // survived; a cookie that vanished with it leaves nothing but a raw error page (#95). `expiresAt`
11
+ // remains the only authority — a stale verifier is never exchanged, no matter how long it is legible.
12
+ const HandoffValidSeconds = 10 * 60;
13
+ const HandoffCookieSeconds = 60 * 60;
5
14
  // What an authorization server answers when `prompt=none` would have worked, but only with somebody
6
15
  // looking at a screen. It is the expected answer to a silent attempt, not a broken deployment.
7
16
  const InteractionRequired = new Set([
@@ -10,12 +19,14 @@ const InteractionRequired = new Set([
10
19
  "consent_required",
11
20
  "account_selection_required",
12
21
  ]);
13
- // One fixed marker, never the portal's own words. `error_description` is a sentence written by the
22
+ // Fixed markers, never the portal's own words. `error_description` is a sentence written by the
14
23
  // authorization server about somebody else's policy: putting it in the URL would hand whatever
15
- // renders the page next a string Intel does not control.
16
- function withConnectError(returnTo) {
24
+ // renders the page next a string Intel does not control. Which marker matters — a refusal is about
25
+ // access, running out of time is not, and a screen that confuses the two sends the reader hunting
26
+ // in the wrong place (#93).
27
+ function withConnectError(returnTo, code = "portal_sign_in_refused") {
17
28
  const url = new URL(returnTo, "https://intel.invalid");
18
- url.searchParams.set("connectError", "portal_sign_in_refused");
29
+ url.searchParams.set("connectError", code);
19
30
  return `${url.pathname}${url.search}`;
20
31
  }
21
32
  function cookieValue(headers) {
@@ -54,11 +65,19 @@ export function createBrowserAuth(deps) {
54
65
  const stored = await deps.clients.get(registrationKey, redirectUri);
55
66
  if (stored)
56
67
  return stored;
57
- const registered = await deps.oauth.register({
68
+ // A refused registration becomes a named state instead of a bare 500 (#93). The conversion
69
+ // must not eat the reason: an IntelError is an expected refusal nobody logs, so the library's
70
+ // own exception is written here — it is the only place that still holds it.
71
+ const registered = await deps.oauth
72
+ .register({
58
73
  issuer,
59
74
  redirectUri,
60
75
  clientName: "Intel",
61
76
  resource,
77
+ })
78
+ .catch((error) => {
79
+ reportUnexpectedError(error);
80
+ throw new IntelError(502, "client_registration_failed", "The authorization server refused the client registration");
62
81
  });
63
82
  return await deps.clients.put({
64
83
  issuer: registrationKey,
@@ -122,6 +141,7 @@ export function createBrowserAuth(deps) {
122
141
  // session is already the person the portal would ask about. `prompt=none` says exactly that:
123
142
  // answer from the session that exists, and refuse rather than show anybody a login (#60).
124
143
  const silent = requestUrl.searchParams.get("silent") === "1";
144
+ const resumed = requestUrl.searchParams.get("resumed") === "1";
125
145
  if (!deps.portalUrl) {
126
146
  throw new IntelError(503, "portal_not_configured", "No MCP portal is configured for this deployment");
127
147
  }
@@ -143,7 +163,8 @@ export function createBrowserAuth(deps) {
143
163
  userId,
144
164
  user,
145
165
  silent,
146
- expiresAt: deps.now().getTime() + 10 * 60 * 1_000,
166
+ resumed,
167
+ expiresAt: deps.now().getTime() + HandoffValidSeconds * 1_000,
147
168
  };
148
169
  const authorizationUrl = await deps.oauth.authorizationUrl({
149
170
  issuer: discovered.issuer,
@@ -155,13 +176,25 @@ export function createBrowserAuth(deps) {
155
176
  ...(discovered.scope ? { scope: discovered.scope } : {}),
156
177
  ...(silent ? { prompt: "none" } : {}),
157
178
  });
158
- return redirect(authorizationUrl.href, sessionCookie(await deps.sessions.seal(pending), secure, 10 * 60));
179
+ return redirect(authorizationUrl.href, sessionCookie(await deps.sessions.seal(pending), secure, HandoffCookieSeconds));
159
180
  },
160
181
  async callback(requestUrl, headers) {
161
182
  const encoded = cookieValue(headers);
162
183
  const pending = encoded ? await deps.sessions.open(encoded) : null;
163
- if ((pending?.kind !== "pending" && pending?.kind !== "connection-pending") ||
164
- pending.expiresAt <= deps.now().getTime()) {
184
+ if (pending?.kind !== "pending" && pending?.kind !== "connection-pending") {
185
+ throw new IntelError(400, "oauth_session_invalid", "OAuth session is missing or expired");
186
+ }
187
+ // A handoff that ran out while somebody was still on the consent screen is not a failure they
188
+ // caused, and everything needed to carry on is in it: their parked Intel session and where
189
+ // they were going. `restore` decides which — it sends them back to the Gate login when that
190
+ // session is gone too, rather than into an attempt that could not finish (#95).
191
+ if (pending.kind === "connection-pending" && pending.expiresAt <= deps.now().getTime()) {
192
+ return await restore(pending.user, pending.returnTo, pending.resumed
193
+ ? withConnectError(pending.returnTo, "portal_sign_in_expired")
194
+ : `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}&resumed=1`);
195
+ }
196
+ // The Gate login handoff parks nothing, so an expired one has nowhere to return to.
197
+ if (pending.expiresAt <= deps.now().getTime()) {
165
198
  throw new IntelError(400, "oauth_session_invalid", "OAuth session is missing or expired");
166
199
  }
167
200
  // ⚠️ An authorization server reports a refusal on the redirect URI, not by failing the token
@@ -78,6 +78,7 @@ export type AuthSession = {
78
78
  userId: string;
79
79
  user: UserAuthSession;
80
80
  silent: boolean;
81
+ resumed: boolean;
81
82
  expiresAt: number;
82
83
  } | UserAuthSession;
83
84
  export interface SessionCodec {
package/dist/http/http.js CHANGED
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
5
5
  import { IntelError } from "../shared/intel-error/intel-error.js";
6
6
  import { problemDetails as problem } from "../shared/problem-details/problem-details.js";
7
+ import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
7
8
  // A query string is a door to the outside like a body is, so what arrives through it is closed
8
9
  // rather than tolerated: `z.strictObject` refuses an unknown field, and this is that refusal for the
9
10
  // half of the input Zod never sees. A silently ignored parameter is how a caller believes it asked
@@ -64,6 +65,9 @@ export function createHttp(deps) {
64
65
  if (error instanceof z.ZodError) {
65
66
  return context.json(problem(400, "invalid_request", "Request validation failed", z.prettifyError(error)), 400);
66
67
  }
68
+ // Same rule as the outer app: an expected refusal explains itself, an unknown exception must
69
+ // leave a trace — otherwise the 500 is a fact without a reason anywhere (#93).
70
+ reportUnexpectedError(error);
67
71
  return context.json(problem(500, "internal_error", "Internal server error"), 500);
68
72
  });
69
73
  app.use("*", async (context, next) => {
@@ -4,6 +4,7 @@ import { handleMcp } from "../mcp/mcp.js";
4
4
  import { authorize, authorizeBearer } from "../shared/gate-authorization/gate-authorization.js";
5
5
  import { IntelError } from "../shared/intel-error/intel-error.js";
6
6
  import { problemDetails } from "../shared/problem-details/problem-details.js";
7
+ import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
7
8
  export function createIntel(deps) {
8
9
  const baseUrl = deps.baseUrl.replace(/\/+$/, "");
9
10
  const resource = `${baseUrl}/mcp`;
@@ -17,6 +18,10 @@ export function createIntel(deps) {
17
18
  if (error instanceof IntelError) {
18
19
  return context.json(problemDetails(error.status, error.code, error.message), error.status);
19
20
  }
21
+ // An IntelError is an expected refusal and explains itself; the unknown exception must leave a
22
+ // trace, or the 500 is undiagnosable — the worker answered, so the platform records no
23
+ // exception of its own (#93). The body stays generic: the log is the operator's channel.
24
+ reportUnexpectedError(error);
20
25
  return context.json(problemDetails(500, "internal_error", "Internal server error"), 500);
21
26
  });
22
27
  app.get("/health", (context) => context.json({ status: "ok" }));
@@ -0,0 +1 @@
1
+ export declare function reportUnexpectedError(error: unknown): void;
@@ -0,0 +1,37 @@
1
+ // A library error can quote whatever the peer sent — an echoed Authorization header, a token in a
2
+ // URL, a response body. The log must show the failure and never the credential, so everything
3
+ // shaped like one is cut before the line is written. The patterns match shapes, not sources: a JWT
4
+ // is recognizable on its own, and other tokens only ever appear behind their label.
5
+ function redact(text) {
6
+ // ⚠️ The order is load-bearing: "Authorization: Bearer x" must hit the bearer rule first — a
7
+ // combined alternation would let the label rule win the leftmost match, swallow the word
8
+ // "Bearer" as the value, and leave the token itself standing.
9
+ return text
10
+ .replace(/\beyJ[\w-]{4,}\.[\w-]+\.[\w-]*/g, "[redacted]")
11
+ .replace(/\b(bearer\s+)[\w.~+/-]+=*/gi, "$1[redacted]")
12
+ .replace(/\b((?:access_token|refresh_token|id_token|client_secret|api_key|authorization)"?\s*[:=]\s*"?)[\w.~+/-]+=*/gi, "$1[redacted]");
13
+ }
14
+ function describe(error) {
15
+ if (error instanceof Error)
16
+ return error.stack ?? `${error.name}: ${error.message}`;
17
+ if (typeof error === "string")
18
+ return error;
19
+ try {
20
+ return JSON.stringify(error);
21
+ }
22
+ catch {
23
+ return String(error);
24
+ }
25
+ }
26
+ // openid-client wraps the informative part — the HTTP response, the OAuth error body — into
27
+ // `cause`, so a log line without the cause chain would name the wrapper and hide the reason (#93).
28
+ const MaxCauseDepth = 5;
29
+ export function reportUnexpectedError(error) {
30
+ const parts = [];
31
+ let current = error;
32
+ for (let depth = 0; depth < MaxCauseDepth && current !== undefined; depth += 1) {
33
+ parts.push(describe(current));
34
+ current = current instanceof Error ? current.cause : undefined;
35
+ }
36
+ console.error(redact(parts.join("\ncaused by: ")));
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {