arc-1 0.9.18 → 0.9.20

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.
Files changed (149) hide show
  1. package/README.md +33 -30
  2. package/dist/adt/config.d.ts +1 -1
  3. package/dist/adt/config.d.ts.map +1 -1
  4. package/dist/adt/errors.d.ts.map +1 -1
  5. package/dist/adt/errors.js +186 -25
  6. package/dist/adt/errors.js.map +1 -1
  7. package/dist/adt/http.d.ts +1 -1
  8. package/dist/adt/http.d.ts.map +1 -1
  9. package/dist/adt/http.js.map +1 -1
  10. package/dist/authz/policy.d.ts +6 -0
  11. package/dist/authz/policy.d.ts.map +1 -1
  12. package/dist/authz/policy.js +20 -0
  13. package/dist/authz/policy.js.map +1 -1
  14. package/dist/cache/cache.d.ts +25 -0
  15. package/dist/cache/cache.d.ts.map +1 -1
  16. package/dist/cache/cache.js.map +1 -1
  17. package/dist/cache/caching-layer.d.ts +31 -2
  18. package/dist/cache/caching-layer.d.ts.map +1 -1
  19. package/dist/cache/caching-layer.js +102 -2
  20. package/dist/cache/caching-layer.js.map +1 -1
  21. package/dist/cache/memory.d.ts +2 -1
  22. package/dist/cache/memory.d.ts.map +1 -1
  23. package/dist/cache/memory.js +46 -0
  24. package/dist/cache/memory.js.map +1 -1
  25. package/dist/cache/sqlite.d.ts +2 -1
  26. package/dist/cache/sqlite.d.ts.map +1 -1
  27. package/dist/cache/sqlite.js +54 -0
  28. package/dist/cache/sqlite.js.map +1 -1
  29. package/dist/cli.js +21 -3
  30. package/dist/cli.js.map +1 -1
  31. package/dist/handlers/context.d.ts.map +1 -1
  32. package/dist/handlers/context.js +27 -4
  33. package/dist/handlers/context.js.map +1 -1
  34. package/dist/handlers/dispatch.d.ts +3 -0
  35. package/dist/handlers/dispatch.d.ts.map +1 -1
  36. package/dist/handlers/dispatch.js +71 -53
  37. package/dist/handlers/dispatch.js.map +1 -1
  38. package/dist/handlers/object-types.d.ts +1 -1
  39. package/dist/handlers/object-types.d.ts.map +1 -1
  40. package/dist/handlers/object-types.js +10 -2
  41. package/dist/handlers/object-types.js.map +1 -1
  42. package/dist/handlers/read.d.ts.map +1 -1
  43. package/dist/handlers/read.js +4 -1
  44. package/dist/handlers/read.js.map +1 -1
  45. package/dist/handlers/schemas.d.ts +8 -0
  46. package/dist/handlers/schemas.d.ts.map +1 -1
  47. package/dist/handlers/schemas.js +2 -0
  48. package/dist/handlers/schemas.js.map +1 -1
  49. package/dist/handlers/tool-registry.d.ts +4 -4
  50. package/dist/handlers/tool-registry.d.ts.map +1 -1
  51. package/dist/handlers/tool-registry.js +4 -0
  52. package/dist/handlers/tool-registry.js.map +1 -1
  53. package/dist/handlers/tools.d.ts.map +1 -1
  54. package/dist/handlers/tools.js +29 -23
  55. package/dist/handlers/tools.js.map +1 -1
  56. package/dist/handlers/write/create.d.ts.map +1 -1
  57. package/dist/handlers/write/create.js +89 -4
  58. package/dist/handlers/write/create.js.map +1 -1
  59. package/dist/plugins/manifest-interpreter.d.ts +25 -0
  60. package/dist/plugins/manifest-interpreter.d.ts.map +1 -0
  61. package/dist/plugins/manifest-interpreter.js +126 -0
  62. package/dist/plugins/manifest-interpreter.js.map +1 -0
  63. package/dist/public/define-tool.d.ts +9 -0
  64. package/dist/public/define-tool.d.ts.map +1 -0
  65. package/dist/public/define-tool.js +25 -0
  66. package/dist/public/define-tool.js.map +1 -0
  67. package/dist/public/index.d.ts +9 -0
  68. package/dist/public/index.d.ts.map +1 -0
  69. package/dist/public/index.js +10 -0
  70. package/dist/public/index.js.map +1 -0
  71. package/dist/public/testing.d.ts +27 -0
  72. package/dist/public/testing.d.ts.map +1 -0
  73. package/dist/public/testing.js +52 -0
  74. package/dist/public/testing.js.map +1 -0
  75. package/dist/public/types.d.ts +87 -0
  76. package/dist/public/types.d.ts.map +1 -0
  77. package/dist/public/types.js +4 -0
  78. package/dist/public/types.js.map +1 -0
  79. package/dist/public/ui/app.js +1044 -0
  80. package/dist/public/ui/arc-mark.svg +5 -0
  81. package/dist/public/ui/index.html +43 -0
  82. package/dist/public/ui/styles.css +563 -0
  83. package/dist/registry/tool-registry.d.ts +74 -0
  84. package/dist/registry/tool-registry.d.ts.map +1 -0
  85. package/dist/registry/tool-registry.js +59 -0
  86. package/dist/registry/tool-registry.js.map +1 -0
  87. package/dist/server/app-url.d.ts +31 -0
  88. package/dist/server/app-url.d.ts.map +1 -0
  89. package/dist/server/app-url.js +50 -0
  90. package/dist/server/app-url.js.map +1 -0
  91. package/dist/server/audit.d.ts +4 -0
  92. package/dist/server/audit.d.ts.map +1 -1
  93. package/dist/server/audit.js.map +1 -1
  94. package/dist/server/config.d.ts.map +1 -1
  95. package/dist/server/config.js +92 -0
  96. package/dist/server/config.js.map +1 -1
  97. package/dist/server/http.d.ts +17 -47
  98. package/dist/server/http.d.ts.map +1 -1
  99. package/dist/server/http.js +127 -376
  100. package/dist/server/http.js.map +1 -1
  101. package/dist/server/logger.d.ts +22 -0
  102. package/dist/server/logger.d.ts.map +1 -1
  103. package/dist/server/logger.js +22 -0
  104. package/dist/server/logger.js.map +1 -1
  105. package/dist/server/plugin-loader.d.ts +19 -0
  106. package/dist/server/plugin-loader.d.ts.map +1 -0
  107. package/dist/server/plugin-loader.js +162 -0
  108. package/dist/server/plugin-loader.js.map +1 -0
  109. package/dist/server/safe-http-client.d.ts +44 -0
  110. package/dist/server/safe-http-client.d.ts.map +1 -0
  111. package/dist/server/safe-http-client.js +198 -0
  112. package/dist/server/safe-http-client.js.map +1 -0
  113. package/dist/server/server.d.ts +2 -2
  114. package/dist/server/server.d.ts.map +1 -1
  115. package/dist/server/server.js +63 -9
  116. package/dist/server/server.js.map +1 -1
  117. package/dist/server/types.d.ts +24 -0
  118. package/dist/server/types.d.ts.map +1 -1
  119. package/dist/server/types.js +6 -0
  120. package/dist/server/types.js.map +1 -1
  121. package/dist/server/ui-log-buffer.d.ts +29 -0
  122. package/dist/server/ui-log-buffer.d.ts.map +1 -0
  123. package/dist/server/ui-log-buffer.js +72 -0
  124. package/dist/server/ui-log-buffer.js.map +1 -0
  125. package/dist/server/ui-state.d.ts +32 -0
  126. package/dist/server/ui-state.d.ts.map +1 -0
  127. package/dist/server/ui-state.js +230 -0
  128. package/dist/server/ui-state.js.map +1 -0
  129. package/dist/server/ui.d.ts +20 -0
  130. package/dist/server/ui.d.ts.map +1 -0
  131. package/dist/server/ui.js +275 -0
  132. package/dist/server/ui.js.map +1 -0
  133. package/package.json +34 -14
  134. package/dist/adt/btp.d.ts +0 -140
  135. package/dist/adt/btp.d.ts.map +0 -1
  136. package/dist/adt/btp.js +0 -427
  137. package/dist/adt/btp.js.map +0 -1
  138. package/dist/server/oauth-state.d.ts +0 -92
  139. package/dist/server/oauth-state.d.ts.map +0 -1
  140. package/dist/server/oauth-state.js +0 -163
  141. package/dist/server/oauth-state.js.map +0 -1
  142. package/dist/server/stateless-client-store.d.ts +0 -173
  143. package/dist/server/stateless-client-store.d.ts.map +0 -1
  144. package/dist/server/stateless-client-store.js +0 -503
  145. package/dist/server/stateless-client-store.js.map +0 -1
  146. package/dist/server/xsuaa.d.ts +0 -188
  147. package/dist/server/xsuaa.d.ts.map +0 -1
  148. package/dist/server/xsuaa.js +0 -464
  149. package/dist/server/xsuaa.js.map +0 -1
@@ -30,237 +30,32 @@ import express from 'express';
30
30
  import helmet from 'helmet';
31
31
  import { expandScopes } from '../authz/policy.js';
32
32
  import { API_KEY_PROFILES } from './config.js';
33
- import { logger } from './logger.js';
33
+ import { authLibLogger, logger } from './logger.js';
34
34
  import { VERSION } from './server.js';
35
- // ─── OAuth Callback Proxy Handler (issue #214) ───────────────────────
35
+ import { mountUiRoutes } from './ui.js';
36
+ // ─── API Key Entry Mapping ───────────────────────────────────────────
36
37
  /**
37
- * Minimal HTML-escape for embedding untrusted text (e.g. an OAuth
38
- * `error_description` from the query string) into the error page below.
38
+ * Map ARC-1's `config.apiKeys` (`{key, profile}[]`) to the package's
39
+ * `ApiKeyEntry[]` (`{key, scopes, clientId}`) so the package's constant-time
40
+ * api-key verifier resolves them identically to ARC-1's previous profile-based
41
+ * matching:
42
+ * - scopes come from `API_KEY_PROFILES[profile]` after `expandScopes`,
43
+ * - clientId is `api-key:<profile>` (same audit/identity string as before).
44
+ * Entries whose profile is unknown are dropped (defense in depth — profiles are
45
+ * already validated at config-parse time). Used by BOTH XSUAA and standard mode.
39
46
  */
40
- function escapeHtml(value) {
41
- return value
42
- .replace(/&/g, '&amp;')
43
- .replace(/</g, '&lt;')
44
- .replace(/>/g, '&gt;')
45
- .replace(/"/g, '&quot;')
46
- .replace(/'/g, '&#39;');
47
- }
48
- /**
49
- * Is this a loopback HTTP redirect URI (`http://localhost|127.0.0.1|[::1]`)?
50
- * Such callbacks are ephemeral local listeners that native MCP clients (GitHub
51
- * Copilot, MCP Inspector) tear down on failure — so on an OAuth error we render
52
- * a self-hosted page for them rather than 302-ing to a dead port. Hosted HTTPS
53
- * callbacks (claude.ai, Copilot Studio) and custom-scheme app callbacks
54
- * (`vscode:`, `cursor:`) are live and expect the spec error redirect, so they
55
- * keep getting it.
56
- */
57
- function isLoopbackHttpRedirect(url) {
58
- if (url.protocol !== 'http:')
59
- return false;
60
- const host = url.hostname.toLowerCase();
61
- return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
62
- }
63
- /**
64
- * Render a self-hosted OAuth error page for `/oauth/callback`. Surfaces the
65
- * IdP's error to the human (loopback MCP clients usually can't — they close
66
- * their listener on failure) with an actionable hint for the most common case,
67
- * `invalid_scope` (authenticated but no granted scopes → an admin must assign an
68
- * ARC-1 role collection under the user's login IdP). `clientReturnUrl` carries
69
- * the error + original state for the rare client still listening.
70
- */
71
- function renderOAuthErrorPage(error, errorDescription, clientReturnUrl) {
72
- const hint = error === 'invalid_scope'
73
- ? 'You are signed in, but your user is not granted any ARC-1 scopes. An administrator must assign you an ARC-1 role collection (for example "ARC-1 Admin") under the identity provider you sign in with — see the ARC-1 authorization docs.'
74
- : 'Retry the sign-in from your MCP client. If it keeps failing, share this error with your ARC-1 administrator.';
75
- const descBlock = errorDescription ? `<p><code>${escapeHtml(errorDescription)}</code></p>` : '';
76
- return ('<!doctype html><html><head><meta charset="utf-8"><title>ARC-1 sign-in failed</title></head>' +
77
- '<body style="font-family:sans-serif;max-width:42rem;margin:3rem auto;padding:0 1rem;line-height:1.5">' +
78
- '<h1>ARC-1 sign-in failed</h1>' +
79
- `<p><strong>Error:</strong> <code>${escapeHtml(error)}</code></p>` +
80
- descBlock +
81
- `<p>${escapeHtml(hint)}</p>` +
82
- `<p><a href="${escapeHtml(clientReturnUrl)}">Return to your application</a></p>` +
83
- '</body></html>');
84
- }
85
- /**
86
- * Express handler for ARC-1's `/oauth/callback`, the second half of the
87
- * XSUAA callback proxy that fixes the `+`-in-state bug (issue #214).
88
- *
89
- * XSUAA redirects here (not to the client) with an opaque base64url `state`
90
- * token that ARC-1's `authorize()` minted. We verify + decode it to recover
91
- * the client's ORIGINAL `redirect_uri` and `state`, then 302 to the client
92
- * re-emitting the state via `URL.searchParams` — whose serializer encodes a
93
- * literal `+` as `%2B`, exactly the encoding the client's parser expects.
94
- *
95
- * Removal condition + upstream tracking (XSUAA root cause, arc-1#214,
96
- * vscode#314715) are documented at the top of `oauth-state.ts`.
97
- *
98
- * SECURITY (authorization-code interception, security audit 2026-06): the
99
- * signed state carries the originating DCR `client_id` (`decoded.clientId`).
100
- * Before forwarding the auth code (or an error) to `decoded.clientRedirectUri`,
101
- * we verify that redirect_uri is actually registered for that client. The
102
- * signature alone is insufficient: all DCR clients share one XSUAA app, so a
103
- * forged-state attack is blocked by the HMAC, but the redirect target must
104
- * still belong to the client that will exchange the code. For stateless DCR
105
- * clients (`arc1-…`) the registered redirect_uris are baked immutably into the
106
- * signed `client_id`, so this check deterministically rejects an attacker who
107
- * substitutes their own redirect_uri on a victim's `client_id`. For the shared
108
- * pre-registered XSUAA default client the redirect_uri is checked against the
109
- * static allowlist (mirrors xs-security.json) instead — `clientStore` makes
110
- * both decisions via `checkRedirectUri`.
111
- *
112
- * Exported for unit tests; mounted in `startHttpServer`. When `clientStore` is
113
- * omitted (legacy unit tests of the issue-#214 round-trip) the binding check is
114
- * skipped; production always passes it.
115
- */
116
- export function createOAuthCallbackHandler(stateCodec, clientStore) {
117
- return async (req, res) => {
118
- const stateToken = typeof req.query.state === 'string' ? req.query.state : '';
119
- const decoded = stateCodec.decode(stateToken);
120
- if (decoded.kind !== 'ok') {
121
- logger.warn('OAuth callback: invalid state token', { reason: decoded.reason });
122
- // We cannot safely redirect anywhere — the client redirect_uri lives
123
- // inside the (unverified) token. Return a terminal error page.
124
- res
125
- .status(400)
126
- .type('html')
127
- .send('<!doctype html><html><body style="font-family:sans-serif;padding:2rem">' +
128
- '<h1>Authentication failed</h1>' +
129
- '<p>The OAuth state token was invalid or expired. Please retry the sign-in from your MCP client.</p>' +
130
- '</body></html>');
131
- return;
132
- }
133
- // ── Client-binding validation (authorization-code interception defense) ──
134
- // Verify the recovered redirect_uri is an allowed target for the client_id
135
- // that minted this state, BEFORE the success or error branches below — so
136
- // neither a code nor an error response is ever steered to an unverified URI.
137
- // The store decides per client type: a DCR client (`arc1-…`) is checked
138
- // against the redirect_uris baked into its signed id; the shared XSUAA
139
- // default client is checked against the static allowlist (mirrors
140
- // xs-security.json), statelessly. Fails CLOSED on any lookup error.
141
- if (clientStore && decoded.clientId) {
142
- let verdict;
143
- try {
144
- verdict = await clientStore.checkRedirectUri(decoded.clientId, decoded.clientRedirectUri);
145
- }
146
- catch (err) {
147
- logger.warn('OAuth callback: redirect_uri check threw — failing closed', {
148
- clientId: decoded.clientId,
149
- error: err instanceof Error ? err.message : String(err),
150
- });
151
- verdict = 'unknown_client';
152
- }
153
- if (verdict === 'unknown_client') {
154
- logger.warn('OAuth callback: state references unknown client_id', { clientId: decoded.clientId });
155
- res
156
- .status(400)
157
- .type('html')
158
- .send('<!doctype html><html><body style="font-family:sans-serif;padding:2rem">' +
159
- '<h1>Authentication failed</h1>' +
160
- '<p>The OAuth client referenced in the state token is no longer valid. Please retry the sign-in.</p>' +
161
- '</body></html>');
162
- return;
163
- }
164
- if (verdict === 'unregistered') {
165
- logger.warn('OAuth callback: redirect_uri not allowed for client', {
166
- clientId: decoded.clientId,
167
- redirectUri: decoded.clientRedirectUri,
168
- });
169
- res
170
- .status(400)
171
- .type('html')
172
- .send('<!doctype html><html><body style="font-family:sans-serif;padding:2rem">' +
173
- '<h1>Authentication failed</h1>' +
174
- '<p>The redirect URI in the state token is not registered for this client. Please retry the sign-in.</p>' +
175
- '</body></html>');
176
- return;
177
- }
178
- }
179
- let target;
180
- try {
181
- target = new URL(decoded.clientRedirectUri);
182
- }
183
- catch {
184
- logger.warn('OAuth callback: stored redirect_uri is not a valid URL');
185
- res.status(400).type('html').send('<!doctype html><html><body>Invalid redirect target.</body></html>');
186
- return;
187
- }
188
- // On error there is no auth code. Forward the error to the client per the
189
- // OAuth spec — EXCEPT for loopback HTTP callbacks. Native MCP clients
190
- // (GitHub Copilot, MCP Inspector, …) tear down their ephemeral localhost
191
- // listener the instant the flow fails, so a 302 there lands on a dead port
192
- // and the user sees a blank ERR_CONNECTION_REFUSED with no clue why. For
193
- // those we render a self-hosted page that surfaces the real reason (e.g.
194
- // invalid_scope → missing role collection), with a best-effort link back.
195
- // Hosted HTTPS callbacks (claude.ai, Copilot Studio) and custom-scheme app
196
- // callbacks (vscode:, cursor:) are live and expect the redirect, so they
197
- // keep getting it.
198
- const error = typeof req.query.error === 'string' ? req.query.error : undefined;
199
- if (error) {
200
- const errorDescription = typeof req.query.error_description === 'string' ? req.query.error_description : '';
201
- if (decoded.clientState !== undefined)
202
- target.searchParams.set('state', decoded.clientState);
203
- target.searchParams.set('error', error);
204
- if (errorDescription)
205
- target.searchParams.set('error_description', errorDescription);
206
- const loopback = isLoopbackHttpRedirect(target);
207
- logger.warn('OAuth callback: identity provider returned an error', {
208
- error,
209
- errorDescriptionPreview: errorDescription.slice(0, 200),
210
- clientRedirectUriHost: target.host,
211
- loopback,
212
- });
213
- if (loopback) {
214
- res
215
- .status(400)
216
- .type('html')
217
- .send(renderOAuthErrorPage(error, errorDescription, target.toString()));
218
- }
219
- else {
220
- res.redirect(302, target.toString());
221
- }
222
- return;
223
- }
224
- // Success: forward the authorization code, re-attaching the client's
225
- // ORIGINAL state. URLSearchParams serialization encodes `+` as `%2B`, which
226
- // is exactly what fixes the round-trip (issue #214).
227
- const code = typeof req.query.code === 'string' ? req.query.code : '';
228
- target.searchParams.set('code', code);
229
- if (decoded.clientState !== undefined) {
230
- target.searchParams.set('state', decoded.clientState);
231
- }
232
- logger.debug('OAuth callback: redirecting to client', {
233
- clientRedirectUriHost: target.host,
234
- hasState: decoded.clientState !== undefined,
235
- });
236
- res.redirect(302, target.toString());
237
- };
238
- }
239
- // ─── API Key Matching Helper ─────────────────────────────────────────
240
- /**
241
- * Match a token against configured API keys (multi-key with profiles).
242
- * Returns the matched entry's profile and scopes, or undefined if no match.
243
- */
244
- function matchApiKey(token, config) {
245
- // Multi-key: each API key has a named profile that maps to a scope set + partial SafetyConfig
246
- if (config.apiKeys) {
247
- for (const entry of config.apiKeys) {
248
- if (token === entry.key) {
249
- const profile = API_KEY_PROFILES[entry.profile];
250
- if (!profile) {
251
- // Should have been caught at config parse; defense in depth
252
- return undefined;
253
- }
254
- const scopes = expandScopes(profile.scopes);
255
- return { profile: entry.profile, scopes, clientId: `api-key:${entry.profile}` };
256
- }
257
- }
47
+ function toApiKeyEntries(config) {
48
+ if (!config.apiKeys)
49
+ return [];
50
+ const entries = [];
51
+ for (const entry of config.apiKeys) {
52
+ const profile = API_KEY_PROFILES[entry.profile];
53
+ if (!profile)
54
+ continue;
55
+ entries.push({ key: entry.key, scopes: expandScopes(profile.scopes), clientId: `api-key:${entry.profile}` });
258
56
  }
259
- return undefined;
57
+ return entries;
260
58
  }
261
- // ─── JWKS / JWT types (lazy-loaded from jose) ────────────────────────
262
- let joseModule = null;
263
- let jwksClient = null;
264
59
  // ─── Security Middleware (helmet + opt-in CORS) ──────────────────────
265
60
  /**
266
61
  * Apply security headers (helmet) and opt-in CORS to an Express app.
@@ -381,7 +176,7 @@ function createMcpHandler(serverFactory) {
381
176
  /**
382
177
  * Start the HTTP Streamable server.
383
178
  */
384
- export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
179
+ export async function startHttpServer(serverFactory, config, xsuaaCredentials, uiDeps) {
385
180
  const [host, portStr] = config.httpAddr.split(':');
386
181
  const port = Number.parseInt(portStr || '8080', 10);
387
182
  const bindHost = host || '0.0.0.0';
@@ -413,6 +208,10 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
413
208
  app.use(express.json());
414
209
  app.use(express.urlencoded({ extended: false }));
415
210
  const mcpHandler = createMcpHandler(serverFactory);
211
+ const hasAdminApiKey = config.apiKeys?.some((entry) => entry.profile === 'admin') ?? false;
212
+ if (uiDeps && !(hasAdminApiKey || config.oidcIssuer || (config.xsuaaAuth && xsuaaCredentials))) {
213
+ throw new Error('ARC1_UI=web requires an admin API key, OIDC, or XSUAA HTTP authentication.');
214
+ }
416
215
  // ─── Global Request Logger ──────────────────────────────────
417
216
  // Log every inbound request for debugging OAuth/MCP flows.
418
217
  app.use((req, _res, next) => {
@@ -437,8 +236,8 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
437
236
  if (config.xsuaaAuth && xsuaaCredentials) {
438
237
  const { mcpAuthRouter } = await import('@modelcontextprotocol/sdk/server/auth/router.js');
439
238
  const { requireBearerAuth } = await import('@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js');
440
- const { createXsuaaOAuthProvider, createChainedTokenVerifier, createXsuaaTokenVerifier } = await import('./xsuaa.js');
441
- const { getAppUrl } = await import('../adt/btp.js');
239
+ const { createXsuaaOAuthProvider, createChainedTokenVerifier, createXsuaaTokenVerifier, createOidcVerifier, createOAuthCallbackHandler, } = await import('@arc-mcp/xsuaa-auth');
240
+ const { getAppUrl } = await import('./app-url.js');
442
241
  // Determine app URL for OAuth metadata
443
242
  const appUrl = getAppUrl() ?? `http://${bindHost}:${port}`;
444
243
  // Compute the prefix-aware public base once, up front — it's needed both
@@ -453,15 +252,33 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
453
252
  // URL sent to XSUAA as redirect_uri; the Express route is mounted at the root
454
253
  // `/oauth/callback` below since the proxy strips the prefix before forwarding.
455
254
  const oauthCallbackUrl = `${oauthFullBase}/oauth/callback`;
456
- // Create XSUAA provider + chained verifier
255
+ // Create XSUAA provider + chained verifier.
256
+ //
257
+ // The package defaults its client_id prefix + KDF labels to `mcp-*`. ARC-1
258
+ // MUST override them to the historical `arc1-*` values so client_ids and
259
+ // OAuth-state tokens minted by previous ARC-1 releases keep validating
260
+ // (the signing-key derivation folds in the KDF label, and the prefix gates
261
+ // `getClient`). These three strings are part of ARC-1's on-the-wire
262
+ // contract — do not change them.
457
263
  const { provider, clientStore, stateCodec } = createXsuaaOAuthProvider(xsuaaCredentials, appUrl, {
264
+ clientIdPrefix: 'arc1-',
265
+ dcrKdfLabel: 'arc1-dcr/v1',
266
+ stateKdfLabel: 'arc1-oauth-state/v1',
458
267
  dcrTtlSeconds: config.oauthDcrTtlSeconds,
459
268
  dcrSigningSecret: config.dcrSigningSecret,
460
269
  callbackUrl: oauthCallbackUrl,
270
+ logger: authLibLogger,
461
271
  });
462
- const xsuaaVerifier = createXsuaaTokenVerifier(xsuaaCredentials);
463
- const oidcVerifier = config.oidcIssuer ? await createOidcVerifier(config) : undefined;
464
- const chainedVerifier = createChainedTokenVerifier(config, xsuaaVerifier, oidcVerifier);
272
+ // Inject ARC-1's scope-expansion policy + logger so the verifier emits the
273
+ // same expanded AuthInfo.scopes ARC-1 produced before the extraction.
274
+ const xsuaaVerifier = createXsuaaTokenVerifier(xsuaaCredentials, { expandScopes, logger: authLibLogger });
275
+ // OIDC verifier comes from the package (hardened: pinned `algorithms`
276
+ // allowlist, no `sub` in debug logs). `buildPackageOidcVerifier` threads
277
+ // ARC-1's historical contract (accepted scopes, `expandScopes`, the
278
+ // `['read']` fallback, clock tolerance). Construction is SYNC — the package
279
+ // lazy-imports jose + memoizes the JWKS on first verify.
280
+ const oidcVerifier = config.oidcIssuer ? buildPackageOidcVerifier(config, createOidcVerifier) : undefined;
281
+ const chainedVerifier = createChainedTokenVerifier({ apiKeys: toApiKeyEntries(config) }, xsuaaVerifier, oidcVerifier, { expandScopes, logger: authLibLogger });
465
282
  // Include resourceMetadataUrl so the 401 WWW-Authenticate header contains the PRM URL.
466
283
  // Copilot Studio (and other PRM-aware clients) use this to discover the OAuth endpoints.
467
284
  const resourceMetadataUrl = `${appUrl}/.well-known/oauth-protected-resource/mcp`;
@@ -469,6 +286,14 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
469
286
  verifier: { verifyAccessToken: chainedVerifier },
470
287
  resourceMetadataUrl,
471
288
  });
289
+ if (uiDeps) {
290
+ const uiBearerAuth = requireBearerAuth({
291
+ verifier: { verifyAccessToken: chainedVerifier },
292
+ requiredScopes: ['admin'],
293
+ resourceMetadataUrl,
294
+ });
295
+ mountUiRoutes(app, uiDeps, uiBearerAuth);
296
+ }
472
297
  // ─── Layer 1: per-IP rate limiters on OAuth endpoints + /mcp ────────
473
298
  // Mounted BEFORE the auth router so spammed credentials are rejected before any
474
299
  // crypto / DB work. Discovery endpoints (/.well-known/*) are intentionally NOT
@@ -583,7 +408,7 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
583
408
  // XSUAA as oauthCallbackUrl. A strip-prefix proxy maps the public path
584
409
  // back to this root route. Handler is extracted (exported) so the
585
410
  // state-round-trip contract is unit-testable without a live XSUAA.
586
- app.get('/oauth/callback', createOAuthCallbackHandler(stateCodec, clientStore));
411
+ app.get('/oauth/callback', createOAuthCallbackHandler(stateCodec, clientStore, { logger: authLibLogger }));
587
412
  // ─── Path-prefix-aware OAuth metadata override ────────────────
588
413
  // The MCP SDK's `mcpAuthRouter` builds endpoint URLs with
589
414
  // `new URL("/authorize", baseUrl).href`, which strips any path component
@@ -668,9 +493,9 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
668
493
  }
669
494
  else {
670
495
  // ─── Standard Auth Mode (API key / OIDC) ─────────────────
671
- if (config.oidcIssuer) {
672
- await initJwks(config.oidcIssuer);
673
- }
496
+ // No JWKS pre-warm needed: the package's OIDC verifier lazy-imports jose and
497
+ // memoizes the remote JWKS on the first verify (and retries on a transient
498
+ // discovery failure instead of caching the rejection).
674
499
  // Layer 1 on /mcp also applies outside XSUAA mode — API-key / OIDC / no-auth
675
500
  // deployments get the same anonymous-probing protection. OAuth endpoints don't
676
501
  // exist in non-XSUAA mode so only /mcp needs mounting here.
@@ -681,8 +506,15 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
681
506
  // Use requireBearerAuth so that authInfo is populated on the MCP request context.
682
507
  // This enables scope enforcement, per-request safety, and principal propagation.
683
508
  const { requireBearerAuth } = await import('@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js');
684
- const verifier = createStandardVerifier(config);
509
+ const verifier = await createStandardVerifier(config);
685
510
  const bearerAuth = requireBearerAuth({ verifier: { verifyAccessToken: verifier } });
511
+ if (uiDeps) {
512
+ const uiBearerAuth = requireBearerAuth({
513
+ verifier: { verifyAccessToken: verifier },
514
+ requiredScopes: ['admin'],
515
+ });
516
+ mountUiRoutes(app, uiDeps, uiBearerAuth);
517
+ }
686
518
  app.all('/mcp', bearerAuth, mcpHandler);
687
519
  }
688
520
  else {
@@ -710,6 +542,7 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
710
542
  addr: `${bindHost}:${port}`,
711
543
  health: `http://${bindHost}:${port}/health`,
712
544
  mcp: `http://${bindHost}:${port}/mcp`,
545
+ ui: uiDeps ? `http://${bindHost}:${port}/ui/` : undefined,
713
546
  auth: authMode,
714
547
  });
715
548
  });
@@ -725,152 +558,70 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
725
558
  process.exit(1);
726
559
  });
727
560
  }
728
- // ─── Standard Mode Verifier ─────────────────────────────────────────
561
+ // ─── OIDC Verifier (package-backed) ─────────────────────────────────
729
562
  /**
730
- * Create a token verifier for standard auth mode (API key + OIDC).
731
- * Returns AuthInfo so the MCP SDK populates extra.authInfo on the request,
732
- * enabling scope enforcement, per-request safety, and principal propagation.
563
+ * ARC-1's recognised scope names the `acceptedScopes` allowlist threaded into
564
+ * the package's OIDC verifier so non-ARC-1 claims (e.g. Entra's `User.Read`) are
565
+ * filtered out, exactly as ARC-1's removed inline `extractOidcScopes` did.
733
566
  */
734
- export function createStandardVerifier(config) {
735
- return async (token) => {
736
- // Lazy-import SDK error classes so bearerAuth maps them to 401/403
737
- const { InvalidTokenError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js');
738
- // API key: match against multi-key map or single key
739
- const apiKeyMatch = matchApiKey(token, config);
740
- if (apiKeyMatch) {
741
- // expiresAt is required by requireBearerAuth — use far-future expiry for static keys
742
- const ONE_YEAR_SECS = 365 * 24 * 60 * 60;
743
- return {
744
- token,
745
- clientId: apiKeyMatch.clientId,
746
- scopes: apiKeyMatch.scopes,
747
- expiresAt: Math.floor(Date.now() / 1000) + ONE_YEAR_SECS,
748
- };
749
- }
750
- // OIDC: validate JWT and extract scopes
751
- if (config.oidcIssuer) {
752
- try {
753
- if (!joseModule || !jwksClient) {
754
- await initJwks(config.oidcIssuer);
755
- }
756
- if (!joseModule || !jwksClient) {
757
- throw new Error('OIDC not initialized — check SAP_OIDC_ISSUER configuration');
758
- }
759
- const { payload } = await joseModule.jwtVerify(token, jwksClient, {
760
- issuer: config.oidcIssuer,
761
- audience: config.oidcAudience,
762
- requiredClaims: ['exp'],
763
- ...(config.oidcClockTolerance != null ? { clockTolerance: config.oidcClockTolerance } : {}),
764
- });
765
- logger.debug('Standard OIDC JWT validated', { sub: payload.sub, iss: payload.iss });
766
- const scopes = extractOidcScopes(payload);
767
- return {
768
- token,
769
- clientId: payload.azp ?? payload.sub ?? 'oidc-user',
770
- scopes,
771
- expiresAt: payload.exp,
772
- extra: { sub: payload.sub, iss: payload.iss },
773
- };
774
- }
775
- catch (err) {
776
- // Wrap JWT validation errors as InvalidTokenError so bearerAuth returns 401
777
- if (err instanceof InvalidTokenError)
778
- throw err;
779
- throw new InvalidTokenError(err.message ?? 'Invalid token');
780
- }
781
- }
782
- throw new InvalidTokenError('Authentication failed: invalid token');
783
- };
784
- }
785
- // ─── OIDC Verifier Factory ───────────────────────────────────────────
786
- /**
787
- * Create an Entra ID / OIDC token verifier using jose.
788
- * Returns a function compatible with the chained verifier.
789
- */
790
- async function createOidcVerifier(config) {
791
- await initJwks(config.oidcIssuer);
792
- return async (token) => {
793
- if (!joseModule || !jwksClient) {
794
- throw new Error('OIDC not initialized');
795
- }
796
- const { payload } = await joseModule.jwtVerify(token, jwksClient, {
797
- issuer: config.oidcIssuer,
798
- audience: config.oidcAudience,
799
- requiredClaims: ['exp'],
800
- ...(config.oidcClockTolerance != null ? { clockTolerance: config.oidcClockTolerance } : {}),
801
- });
802
- logger.debug('OIDC JWT validated', { sub: payload.sub, iss: payload.iss });
803
- const scopes = extractOidcScopes(payload);
804
- return {
805
- token,
806
- clientId: payload.azp ?? payload.sub ?? 'oidc-user',
807
- scopes,
808
- expiresAt: payload.exp,
809
- extra: { sub: payload.sub, iss: payload.iss },
810
- };
811
- };
812
- }
813
- // ─── OIDC Scope Extraction ──────────────────────────────────────────
814
567
  const KNOWN_SCOPES = ['read', 'write', 'data', 'sql', 'transports', 'git', 'admin'];
815
568
  /**
816
- * Extract scopes from an OIDC JWT payload.
569
+ * Build the package's OIDC verifier from `ServerConfig`, threading ARC-1's
570
+ * historical contract so adopting it changes nothing observable except the two
571
+ * intended hardening wins (pinned `algorithms` allowlist + no `sub` in the debug
572
+ * log — both live inside the package now):
573
+ * - `acceptedScopes = KNOWN_SCOPES` (same filter as the old inline code),
574
+ * - `expandScopes` = ARC-1's policy expander (implied scopes: write⊇read, …),
575
+ * - `fallbackScopes: ['read']` = ARC-1's legacy default — a verified token with
576
+ * no scope claims, OR scope claims none of which are accepted, grants `['read']`
577
+ * (the package defaults this to `[]`; ARC-1 opts back into read-only),
578
+ * - `scopeClaim` left default (`'scope'`, preferred over `scp`),
579
+ * - `clockToleranceSec` only when `config.oidcClockTolerance` is set.
817
580
  *
818
- * Tries `scope` (space-separated string, standard OIDC) then `scp` (array, Azure AD style).
819
- * Filters to known scopes, applies implied scope expansion, and falls back to read-only
820
- * when no scope claims are present (safe default for providers that don't emit scopes).
581
+ * `config.oidcAudience` is optional in ARC-1 and was historically forwarded to
582
+ * jose verbatim (jose treats `undefined` as "skip the audience check"); the
583
+ * package forwards it the same way, so we pass it through unchanged. The package
584
+ * signature types `audience` as `string`; the cast preserves the
585
+ * undefined-passthrough without an empty-string audience that would force a
586
+ * (failing) audience check. `config.oidcIssuer` is asserted non-null — every
587
+ * call site is gated on `config.oidcIssuer`.
588
+ *
589
+ * Construction is synchronous: the package lazy-imports jose and memoizes the
590
+ * remote JWKS on the first verify.
821
591
  */
822
- export function extractOidcScopes(payload) {
823
- let rawScopes;
824
- // Standard OIDC: space-separated string
825
- if (typeof payload.scope === 'string') {
826
- rawScopes = payload.scope.split(' ').filter((s) => s.length > 0);
827
- }
828
- // Azure AD / Entra: `scp` as space-delimited string (delegated tokens) or array (app tokens)
829
- else if (typeof payload.scp === 'string') {
830
- rawScopes = payload.scp.split(' ').filter((s) => s.length > 0);
831
- }
832
- else if (Array.isArray(payload.scp)) {
833
- rawScopes = payload.scp.filter((s) => typeof s === 'string' && s.length > 0);
834
- }
835
- // No scope claims at all → read-only (safe default)
836
- if (rawScopes === undefined) {
837
- logger.warn('OIDC JWT has no scope/scp claims — granting read-only access. ' +
838
- 'Configure scope claims in your OIDC provider to grant write/data/sql access.');
839
- return ['read'];
840
- }
841
- // Filter to known scopes
842
- const filtered = rawScopes.filter((s) => KNOWN_SCOPES.includes(s));
843
- // If scopes were present but none are known, grant minimum read access
844
- if (filtered.length === 0) {
845
- logger.warn('OIDC JWT has scope claims but none match known scopes — granting read-only', { rawScopes });
846
- return ['read'];
847
- }
848
- return expandScopes(filtered);
592
+ function buildPackageOidcVerifier(config, createOidcVerifier) {
593
+ return createOidcVerifier(config.oidcIssuer, config.oidcAudience, {
594
+ acceptedScopes: KNOWN_SCOPES,
595
+ expandScopes,
596
+ fallbackScopes: ['read'],
597
+ ...(config.oidcClockTolerance != null ? { clockToleranceSec: config.oidcClockTolerance } : {}),
598
+ logger: authLibLogger,
599
+ });
849
600
  }
601
+ // ─── Standard Mode Verifier ─────────────────────────────────────────
850
602
  /**
851
- * Initialize JWKS client from OIDC discovery.
603
+ * Create a token verifier for standard auth mode (API key + OIDC).
604
+ *
605
+ * Built entirely from the `@arc-mcp/xsuaa-auth` package's chained verifier
606
+ * (constant-time api-key compare + hardened OIDC), mirroring how XSUAA mode wires
607
+ * its chain — minus the XSUAA verifier, which standard mode doesn't have. The
608
+ * chain order is XSUAA → OIDC → api-key; with no XSUAA verifier that collapses to
609
+ * OIDC → api-key, which is order-immaterial here (token types are disjoint) and
610
+ * preserves the previous behavior (api-key matched first only mattered because
611
+ * the two paths never overlap). Returns `AuthInfo` so the MCP SDK populates
612
+ * `extra.authInfo` on the request context, enabling scope enforcement,
613
+ * per-request safety, and principal propagation.
614
+ *
615
+ * Async because the package is dynamic-imported (lazy, consistent with the rest
616
+ * of this module); the returned verifier itself is the package's sync-built
617
+ * chained verifier.
852
618
  */
853
- async function initJwks(issuer) {
854
- if (joseModule && jwksClient)
855
- return;
856
- try {
857
- if (!joseModule) {
858
- joseModule = await import('jose');
859
- }
860
- const jwksUri = new URL('.well-known/openid-configuration', issuer.endsWith('/') ? issuer : `${issuer}/`);
861
- const discoveryResp = await fetch(jwksUri.toString());
862
- const discovery = (await discoveryResp.json());
863
- if (!discovery.jwks_uri) {
864
- throw new Error(`No jwks_uri in OIDC discovery response from ${jwksUri}`);
865
- }
866
- jwksClient = joseModule.createRemoteJWKSet(new URL(discovery.jwks_uri));
867
- logger.info('OIDC JWKS initialized', { issuer, jwksUri: discovery.jwks_uri });
868
- }
869
- catch (err) {
870
- logger.error('Failed to initialize OIDC JWKS', {
871
- issuer,
872
- error: err instanceof Error ? err.message : String(err),
873
- });
874
- }
619
+ export async function createStandardVerifier(config) {
620
+ const { createChainedTokenVerifier, createOidcVerifier } = await import('@arc-mcp/xsuaa-auth');
621
+ const oidcVerifier = config.oidcIssuer ? buildPackageOidcVerifier(config, createOidcVerifier) : undefined;
622
+ return createChainedTokenVerifier({ apiKeys: toApiKeyEntries(config) }, undefined, oidcVerifier, {
623
+ expandScopes,
624
+ logger: authLibLogger,
625
+ });
875
626
  }
876
627
  //# sourceMappingURL=http.js.map