arc-1 0.9.18 → 0.9.19

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 (92) hide show
  1. package/README.md +26 -26
  2. package/dist/adt/config.d.ts +1 -1
  3. package/dist/adt/config.d.ts.map +1 -1
  4. package/dist/adt/http.d.ts +1 -1
  5. package/dist/adt/http.d.ts.map +1 -1
  6. package/dist/adt/http.js.map +1 -1
  7. package/dist/authz/policy.d.ts +6 -0
  8. package/dist/authz/policy.d.ts.map +1 -1
  9. package/dist/authz/policy.js +20 -0
  10. package/dist/authz/policy.js.map +1 -1
  11. package/dist/cli.js +21 -3
  12. package/dist/cli.js.map +1 -1
  13. package/dist/handlers/dispatch.d.ts +3 -0
  14. package/dist/handlers/dispatch.d.ts.map +1 -1
  15. package/dist/handlers/dispatch.js +71 -53
  16. package/dist/handlers/dispatch.js.map +1 -1
  17. package/dist/handlers/schemas.d.ts +4 -4
  18. package/dist/plugins/manifest-interpreter.d.ts +25 -0
  19. package/dist/plugins/manifest-interpreter.d.ts.map +1 -0
  20. package/dist/plugins/manifest-interpreter.js +124 -0
  21. package/dist/plugins/manifest-interpreter.js.map +1 -0
  22. package/dist/public/define-tool.d.ts +9 -0
  23. package/dist/public/define-tool.d.ts.map +1 -0
  24. package/dist/public/define-tool.js +25 -0
  25. package/dist/public/define-tool.js.map +1 -0
  26. package/dist/public/index.d.ts +9 -0
  27. package/dist/public/index.d.ts.map +1 -0
  28. package/dist/public/index.js +10 -0
  29. package/dist/public/index.js.map +1 -0
  30. package/dist/public/testing.d.ts +26 -0
  31. package/dist/public/testing.d.ts.map +1 -0
  32. package/dist/public/testing.js +39 -0
  33. package/dist/public/testing.js.map +1 -0
  34. package/dist/public/types.d.ts +87 -0
  35. package/dist/public/types.d.ts.map +1 -0
  36. package/dist/public/types.js +4 -0
  37. package/dist/public/types.js.map +1 -0
  38. package/dist/registry/tool-registry.d.ts +74 -0
  39. package/dist/registry/tool-registry.d.ts.map +1 -0
  40. package/dist/registry/tool-registry.js +59 -0
  41. package/dist/registry/tool-registry.js.map +1 -0
  42. package/dist/server/app-url.d.ts +31 -0
  43. package/dist/server/app-url.d.ts.map +1 -0
  44. package/dist/server/app-url.js +50 -0
  45. package/dist/server/app-url.js.map +1 -0
  46. package/dist/server/audit.d.ts +4 -0
  47. package/dist/server/audit.d.ts.map +1 -1
  48. package/dist/server/audit.js.map +1 -1
  49. package/dist/server/config.d.ts.map +1 -1
  50. package/dist/server/config.js +19 -0
  51. package/dist/server/config.js.map +1 -1
  52. package/dist/server/http.d.ts +15 -46
  53. package/dist/server/http.d.ts.map +1 -1
  54. package/dist/server/http.js +105 -375
  55. package/dist/server/http.js.map +1 -1
  56. package/dist/server/logger.d.ts +22 -0
  57. package/dist/server/logger.d.ts.map +1 -1
  58. package/dist/server/logger.js +22 -0
  59. package/dist/server/logger.js.map +1 -1
  60. package/dist/server/plugin-loader.d.ts +19 -0
  61. package/dist/server/plugin-loader.d.ts.map +1 -0
  62. package/dist/server/plugin-loader.js +162 -0
  63. package/dist/server/plugin-loader.js.map +1 -0
  64. package/dist/server/safe-http-client.d.ts +38 -0
  65. package/dist/server/safe-http-client.d.ts.map +1 -0
  66. package/dist/server/safe-http-client.js +129 -0
  67. package/dist/server/safe-http-client.js.map +1 -0
  68. package/dist/server/server.d.ts +2 -2
  69. package/dist/server/server.d.ts.map +1 -1
  70. package/dist/server/server.js +36 -7
  71. package/dist/server/server.js.map +1 -1
  72. package/dist/server/types.d.ts +8 -0
  73. package/dist/server/types.d.ts.map +1 -1
  74. package/dist/server/types.js +2 -0
  75. package/dist/server/types.js.map +1 -1
  76. package/package.json +24 -8
  77. package/dist/adt/btp.d.ts +0 -140
  78. package/dist/adt/btp.d.ts.map +0 -1
  79. package/dist/adt/btp.js +0 -427
  80. package/dist/adt/btp.js.map +0 -1
  81. package/dist/server/oauth-state.d.ts +0 -92
  82. package/dist/server/oauth-state.d.ts.map +0 -1
  83. package/dist/server/oauth-state.js +0 -163
  84. package/dist/server/oauth-state.js.map +0 -1
  85. package/dist/server/stateless-client-store.d.ts +0 -173
  86. package/dist/server/stateless-client-store.d.ts.map +0 -1
  87. package/dist/server/stateless-client-store.js +0 -503
  88. package/dist/server/stateless-client-store.js.map +0 -1
  89. package/dist/server/xsuaa.d.ts +0 -188
  90. package/dist/server/xsuaa.d.ts.map +0 -1
  91. package/dist/server/xsuaa.js +0 -464
  92. package/dist/server/xsuaa.js.map +0 -1
@@ -24,45 +24,10 @@
24
24
  *
25
25
  * 4. Health endpoint is always unauthenticated — needed for CF health checks.
26
26
  */
27
+ import type { XsuaaCredentials } from '@arc-mcp/xsuaa-auth';
27
28
  import type { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
28
- import type { Request, Response } from 'express';
29
29
  import express from 'express';
30
- import type { OAuthStateCodec } from './oauth-state.js';
31
- import type { StatelessDcrClientStore } from './stateless-client-store.js';
32
30
  import type { ServerConfig } from './types.js';
33
- import type { XsuaaCredentials } from './xsuaa.js';
34
- /**
35
- * Express handler for ARC-1's `/oauth/callback`, the second half of the
36
- * XSUAA callback proxy that fixes the `+`-in-state bug (issue #214).
37
- *
38
- * XSUAA redirects here (not to the client) with an opaque base64url `state`
39
- * token that ARC-1's `authorize()` minted. We verify + decode it to recover
40
- * the client's ORIGINAL `redirect_uri` and `state`, then 302 to the client
41
- * re-emitting the state via `URL.searchParams` — whose serializer encodes a
42
- * literal `+` as `%2B`, exactly the encoding the client's parser expects.
43
- *
44
- * Removal condition + upstream tracking (XSUAA root cause, arc-1#214,
45
- * vscode#314715) are documented at the top of `oauth-state.ts`.
46
- *
47
- * SECURITY (authorization-code interception, security audit 2026-06): the
48
- * signed state carries the originating DCR `client_id` (`decoded.clientId`).
49
- * Before forwarding the auth code (or an error) to `decoded.clientRedirectUri`,
50
- * we verify that redirect_uri is actually registered for that client. The
51
- * signature alone is insufficient: all DCR clients share one XSUAA app, so a
52
- * forged-state attack is blocked by the HMAC, but the redirect target must
53
- * still belong to the client that will exchange the code. For stateless DCR
54
- * clients (`arc1-…`) the registered redirect_uris are baked immutably into the
55
- * signed `client_id`, so this check deterministically rejects an attacker who
56
- * substitutes their own redirect_uri on a victim's `client_id`. For the shared
57
- * pre-registered XSUAA default client the redirect_uri is checked against the
58
- * static allowlist (mirrors xs-security.json) instead — `clientStore` makes
59
- * both decisions via `checkRedirectUri`.
60
- *
61
- * Exported for unit tests; mounted in `startHttpServer`. When `clientStore` is
62
- * omitted (legacy unit tests of the issue-#214 round-trip) the binding check is
63
- * skipped; production always passes it.
64
- */
65
- export declare function createOAuthCallbackHandler(stateCodec: OAuthStateCodec, clientStore?: StatelessDcrClientStore): (req: Request, res: Response) => Promise<void>;
66
31
  /**
67
32
  * Apply security headers (helmet) and opt-in CORS to an Express app.
68
33
  *
@@ -93,16 +58,20 @@ export declare function applySecurityMiddleware(app: express.Application, allowe
93
58
  export declare function startHttpServer(serverFactory: () => McpServer, config: ServerConfig, xsuaaCredentials?: XsuaaCredentials): Promise<void>;
94
59
  /**
95
60
  * Create a token verifier for standard auth mode (API key + OIDC).
96
- * Returns AuthInfo so the MCP SDK populates extra.authInfo on the request,
97
- * enabling scope enforcement, per-request safety, and principal propagation.
98
- */
99
- export declare function createStandardVerifier(config: ServerConfig): (token: string) => Promise<import('@modelcontextprotocol/sdk/server/auth/types.js').AuthInfo>;
100
- /**
101
- * Extract scopes from an OIDC JWT payload.
102
61
  *
103
- * Tries `scope` (space-separated string, standard OIDC) then `scp` (array, Azure AD style).
104
- * Filters to known scopes, applies implied scope expansion, and falls back to read-only
105
- * when no scope claims are present (safe default for providers that don't emit scopes).
62
+ * Built entirely from the `@arc-mcp/xsuaa-auth` package's chained verifier
63
+ * (constant-time api-key compare + hardened OIDC), mirroring how XSUAA mode wires
64
+ * its chain minus the XSUAA verifier, which standard mode doesn't have. The
65
+ * chain order is XSUAA → OIDC → api-key; with no XSUAA verifier that collapses to
66
+ * OIDC → api-key, which is order-immaterial here (token types are disjoint) and
67
+ * preserves the previous behavior (api-key matched first only mattered because
68
+ * the two paths never overlap). Returns `AuthInfo` so the MCP SDK populates
69
+ * `extra.authInfo` on the request context, enabling scope enforcement,
70
+ * per-request safety, and principal propagation.
71
+ *
72
+ * Async because the package is dynamic-imported (lazy, consistent with the rest
73
+ * of this module); the returned verifier itself is the package's sync-built
74
+ * chained verifier.
106
75
  */
107
- export declare function extractOidcScopes(payload: Record<string, unknown>): string[];
76
+ export declare function createStandardVerifier(config: ServerConfig): Promise<import('@arc-mcp/xsuaa-auth').Verifier>;
108
77
  //# sourceMappingURL=http.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,2CAA2C,CAAC;AAGrF,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,OAAO,MAAM,SAAS,CAAC;AAK9B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAExD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AAC3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AA0DnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,uBAAuB,IAC7F,KAAK,OAAO,EAAE,KAAK,QAAQ,KAAG,OAAO,CAAC,IAAI,CAAC,CA+H1D;AAoCD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,CAAC,WAAW,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,CA6DhG;AAuCD;;GAEG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,MAAM,SAAS,EAC9B,MAAM,EAAE,YAAY,EACpB,gBAAgB,CAAC,EAAE,gBAAgB,GAClC,OAAO,CAAC,IAAI,CAAC,CAqXf;AAID;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,YAAY,GACnB,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,gDAAgD,EAAE,QAAQ,CAAC,CAsD/F;AA0CD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAiC5E"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,2CAA2C,CAAC;AAIrF,OAAO,OAAO,MAAM,SAAS,CAAC;AAM9B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AA2B/C;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,CAAC,WAAW,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,CA6DhG;AAuCD;;GAEG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,MAAM,SAAS,EAC9B,MAAM,EAAE,YAAY,EACpB,gBAAgB,CAAC,EAAE,gBAAgB,GAClC,OAAO,CAAC,IAAI,CAAC,CAgZf;AAkDD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,qBAAqB,EAAE,QAAQ,CAAC,CAOlH"}
@@ -30,237 +30,31 @@ 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
+ // ─── API Key Entry Mapping ───────────────────────────────────────────
36
36
  /**
37
- * Minimal HTML-escape for embedding untrusted text (e.g. an OAuth
38
- * `error_description` from the query string) into the error page below.
37
+ * Map ARC-1's `config.apiKeys` (`{key, profile}[]`) to the package's
38
+ * `ApiKeyEntry[]` (`{key, scopes, clientId}`) so the package's constant-time
39
+ * api-key verifier resolves them identically to ARC-1's previous profile-based
40
+ * matching:
41
+ * - scopes come from `API_KEY_PROFILES[profile]` after `expandScopes`,
42
+ * - clientId is `api-key:<profile>` (same audit/identity string as before).
43
+ * Entries whose profile is unknown are dropped (defense in depth — profiles are
44
+ * already validated at config-parse time). Used by BOTH XSUAA and standard mode.
39
45
  */
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
- }
46
+ function toApiKeyEntries(config) {
47
+ if (!config.apiKeys)
48
+ return [];
49
+ const entries = [];
50
+ for (const entry of config.apiKeys) {
51
+ const profile = API_KEY_PROFILES[entry.profile];
52
+ if (!profile)
53
+ continue;
54
+ entries.push({ key: entry.key, scopes: expandScopes(profile.scopes), clientId: `api-key:${entry.profile}` });
258
55
  }
259
- return undefined;
56
+ return entries;
260
57
  }
261
- // ─── JWKS / JWT types (lazy-loaded from jose) ────────────────────────
262
- let joseModule = null;
263
- let jwksClient = null;
264
58
  // ─── Security Middleware (helmet + opt-in CORS) ──────────────────────
265
59
  /**
266
60
  * Apply security headers (helmet) and opt-in CORS to an Express app.
@@ -437,8 +231,8 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
437
231
  if (config.xsuaaAuth && xsuaaCredentials) {
438
232
  const { mcpAuthRouter } = await import('@modelcontextprotocol/sdk/server/auth/router.js');
439
233
  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');
234
+ const { createXsuaaOAuthProvider, createChainedTokenVerifier, createXsuaaTokenVerifier, createOidcVerifier, createOAuthCallbackHandler, } = await import('@arc-mcp/xsuaa-auth');
235
+ const { getAppUrl } = await import('./app-url.js');
442
236
  // Determine app URL for OAuth metadata
443
237
  const appUrl = getAppUrl() ?? `http://${bindHost}:${port}`;
444
238
  // Compute the prefix-aware public base once, up front — it's needed both
@@ -453,15 +247,33 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
453
247
  // URL sent to XSUAA as redirect_uri; the Express route is mounted at the root
454
248
  // `/oauth/callback` below since the proxy strips the prefix before forwarding.
455
249
  const oauthCallbackUrl = `${oauthFullBase}/oauth/callback`;
456
- // Create XSUAA provider + chained verifier
250
+ // Create XSUAA provider + chained verifier.
251
+ //
252
+ // The package defaults its client_id prefix + KDF labels to `mcp-*`. ARC-1
253
+ // MUST override them to the historical `arc1-*` values so client_ids and
254
+ // OAuth-state tokens minted by previous ARC-1 releases keep validating
255
+ // (the signing-key derivation folds in the KDF label, and the prefix gates
256
+ // `getClient`). These three strings are part of ARC-1's on-the-wire
257
+ // contract — do not change them.
457
258
  const { provider, clientStore, stateCodec } = createXsuaaOAuthProvider(xsuaaCredentials, appUrl, {
259
+ clientIdPrefix: 'arc1-',
260
+ dcrKdfLabel: 'arc1-dcr/v1',
261
+ stateKdfLabel: 'arc1-oauth-state/v1',
458
262
  dcrTtlSeconds: config.oauthDcrTtlSeconds,
459
263
  dcrSigningSecret: config.dcrSigningSecret,
460
264
  callbackUrl: oauthCallbackUrl,
265
+ logger: authLibLogger,
461
266
  });
462
- const xsuaaVerifier = createXsuaaTokenVerifier(xsuaaCredentials);
463
- const oidcVerifier = config.oidcIssuer ? await createOidcVerifier(config) : undefined;
464
- const chainedVerifier = createChainedTokenVerifier(config, xsuaaVerifier, oidcVerifier);
267
+ // Inject ARC-1's scope-expansion policy + logger so the verifier emits the
268
+ // same expanded AuthInfo.scopes ARC-1 produced before the extraction.
269
+ const xsuaaVerifier = createXsuaaTokenVerifier(xsuaaCredentials, { expandScopes, logger: authLibLogger });
270
+ // OIDC verifier comes from the package (hardened: pinned `algorithms`
271
+ // allowlist, no `sub` in debug logs). `buildPackageOidcVerifier` threads
272
+ // ARC-1's historical contract (accepted scopes, `expandScopes`, the
273
+ // `['read']` fallback, clock tolerance). Construction is SYNC — the package
274
+ // lazy-imports jose + memoizes the JWKS on first verify.
275
+ const oidcVerifier = config.oidcIssuer ? buildPackageOidcVerifier(config, createOidcVerifier) : undefined;
276
+ const chainedVerifier = createChainedTokenVerifier({ apiKeys: toApiKeyEntries(config) }, xsuaaVerifier, oidcVerifier, { expandScopes, logger: authLibLogger });
465
277
  // Include resourceMetadataUrl so the 401 WWW-Authenticate header contains the PRM URL.
466
278
  // Copilot Studio (and other PRM-aware clients) use this to discover the OAuth endpoints.
467
279
  const resourceMetadataUrl = `${appUrl}/.well-known/oauth-protected-resource/mcp`;
@@ -583,7 +395,7 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
583
395
  // XSUAA as oauthCallbackUrl. A strip-prefix proxy maps the public path
584
396
  // back to this root route. Handler is extracted (exported) so the
585
397
  // state-round-trip contract is unit-testable without a live XSUAA.
586
- app.get('/oauth/callback', createOAuthCallbackHandler(stateCodec, clientStore));
398
+ app.get('/oauth/callback', createOAuthCallbackHandler(stateCodec, clientStore, { logger: authLibLogger }));
587
399
  // ─── Path-prefix-aware OAuth metadata override ────────────────
588
400
  // The MCP SDK's `mcpAuthRouter` builds endpoint URLs with
589
401
  // `new URL("/authorize", baseUrl).href`, which strips any path component
@@ -668,9 +480,9 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
668
480
  }
669
481
  else {
670
482
  // ─── Standard Auth Mode (API key / OIDC) ─────────────────
671
- if (config.oidcIssuer) {
672
- await initJwks(config.oidcIssuer);
673
- }
483
+ // No JWKS pre-warm needed: the package's OIDC verifier lazy-imports jose and
484
+ // memoizes the remote JWKS on the first verify (and retries on a transient
485
+ // discovery failure instead of caching the rejection).
674
486
  // Layer 1 on /mcp also applies outside XSUAA mode — API-key / OIDC / no-auth
675
487
  // deployments get the same anonymous-probing protection. OAuth endpoints don't
676
488
  // exist in non-XSUAA mode so only /mcp needs mounting here.
@@ -681,7 +493,7 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
681
493
  // Use requireBearerAuth so that authInfo is populated on the MCP request context.
682
494
  // This enables scope enforcement, per-request safety, and principal propagation.
683
495
  const { requireBearerAuth } = await import('@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js');
684
- const verifier = createStandardVerifier(config);
496
+ const verifier = await createStandardVerifier(config);
685
497
  const bearerAuth = requireBearerAuth({ verifier: { verifyAccessToken: verifier } });
686
498
  app.all('/mcp', bearerAuth, mcpHandler);
687
499
  }
@@ -725,152 +537,70 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials) {
725
537
  process.exit(1);
726
538
  });
727
539
  }
728
- // ─── Standard Mode Verifier ─────────────────────────────────────────
540
+ // ─── OIDC Verifier (package-backed) ─────────────────────────────────
729
541
  /**
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.
542
+ * ARC-1's recognised scope names the `acceptedScopes` allowlist threaded into
543
+ * the package's OIDC verifier so non-ARC-1 claims (e.g. Entra's `User.Read`) are
544
+ * filtered out, exactly as ARC-1's removed inline `extractOidcScopes` did.
733
545
  */
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
546
  const KNOWN_SCOPES = ['read', 'write', 'data', 'sql', 'transports', 'git', 'admin'];
815
547
  /**
816
- * Extract scopes from an OIDC JWT payload.
548
+ * Build the package's OIDC verifier from `ServerConfig`, threading ARC-1's
549
+ * historical contract so adopting it changes nothing observable except the two
550
+ * intended hardening wins (pinned `algorithms` allowlist + no `sub` in the debug
551
+ * log — both live inside the package now):
552
+ * - `acceptedScopes = KNOWN_SCOPES` (same filter as the old inline code),
553
+ * - `expandScopes` = ARC-1's policy expander (implied scopes: write⊇read, …),
554
+ * - `fallbackScopes: ['read']` = ARC-1's legacy default — a verified token with
555
+ * no scope claims, OR scope claims none of which are accepted, grants `['read']`
556
+ * (the package defaults this to `[]`; ARC-1 opts back into read-only),
557
+ * - `scopeClaim` left default (`'scope'`, preferred over `scp`),
558
+ * - `clockToleranceSec` only when `config.oidcClockTolerance` is set.
559
+ *
560
+ * `config.oidcAudience` is optional in ARC-1 and was historically forwarded to
561
+ * jose verbatim (jose treats `undefined` as "skip the audience check"); the
562
+ * package forwards it the same way, so we pass it through unchanged. The package
563
+ * signature types `audience` as `string`; the cast preserves the
564
+ * undefined-passthrough without an empty-string audience that would force a
565
+ * (failing) audience check. `config.oidcIssuer` is asserted non-null — every
566
+ * call site is gated on `config.oidcIssuer`.
817
567
  *
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).
568
+ * Construction is synchronous: the package lazy-imports jose and memoizes the
569
+ * remote JWKS on the first verify.
821
570
  */
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);
571
+ function buildPackageOidcVerifier(config, createOidcVerifier) {
572
+ return createOidcVerifier(config.oidcIssuer, config.oidcAudience, {
573
+ acceptedScopes: KNOWN_SCOPES,
574
+ expandScopes,
575
+ fallbackScopes: ['read'],
576
+ ...(config.oidcClockTolerance != null ? { clockToleranceSec: config.oidcClockTolerance } : {}),
577
+ logger: authLibLogger,
578
+ });
849
579
  }
580
+ // ─── Standard Mode Verifier ─────────────────────────────────────────
850
581
  /**
851
- * Initialize JWKS client from OIDC discovery.
582
+ * Create a token verifier for standard auth mode (API key + OIDC).
583
+ *
584
+ * Built entirely from the `@arc-mcp/xsuaa-auth` package's chained verifier
585
+ * (constant-time api-key compare + hardened OIDC), mirroring how XSUAA mode wires
586
+ * its chain — minus the XSUAA verifier, which standard mode doesn't have. The
587
+ * chain order is XSUAA → OIDC → api-key; with no XSUAA verifier that collapses to
588
+ * OIDC → api-key, which is order-immaterial here (token types are disjoint) and
589
+ * preserves the previous behavior (api-key matched first only mattered because
590
+ * the two paths never overlap). Returns `AuthInfo` so the MCP SDK populates
591
+ * `extra.authInfo` on the request context, enabling scope enforcement,
592
+ * per-request safety, and principal propagation.
593
+ *
594
+ * Async because the package is dynamic-imported (lazy, consistent with the rest
595
+ * of this module); the returned verifier itself is the package's sync-built
596
+ * chained verifier.
852
597
  */
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
- }
598
+ export async function createStandardVerifier(config) {
599
+ const { createChainedTokenVerifier, createOidcVerifier } = await import('@arc-mcp/xsuaa-auth');
600
+ const oidcVerifier = config.oidcIssuer ? buildPackageOidcVerifier(config, createOidcVerifier) : undefined;
601
+ return createChainedTokenVerifier({ apiKeys: toApiKeyEntries(config) }, undefined, oidcVerifier, {
602
+ expandScopes,
603
+ logger: authLibLogger,
604
+ });
875
605
  }
876
606
  //# sourceMappingURL=http.js.map