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
@@ -1,188 +0,0 @@
1
- /**
2
- * XSUAA OAuth proxy for MCP-native clients.
3
- *
4
- * Enables Claude Desktop, Cursor, VS Code, and MCP Inspector to authenticate
5
- * via BTP XSUAA using the MCP specification's OAuth discovery (RFC 8414).
6
- *
7
- * Uses the MCP SDK's ProxyOAuthServerProvider to delegate the OAuth flow
8
- * to XSUAA, and @sap/xssec for SAP-specific JWT validation.
9
- *
10
- * Design decisions:
11
- *
12
- * 1. @sap/xssec for token validation (not jose):
13
- * - SAP-specific x5t thumbprint and proof-of-possession validation
14
- * - Proper XSUAA audience format handling
15
- * - Offline validation with automatic JWKS caching
16
- * - checkLocalScope() for scope enforcement
17
- *
18
- * 2. Stateless DCR client store (StatelessDcrClientStore):
19
- * - MCP clients (Claude Desktop, Cursor) register dynamically via RFC 7591
20
- * - client_ids are HMAC-signed by the XSUAA clientsecret, so they
21
- * survive restarts / pushes / cell moves without any backing store
22
- * - XSUAA clientId is pre-registered as the default client
23
- *
24
- * 3. Chained token verifier:
25
- * - Tries XSUAA → Entra ID OIDC → API key in order
26
- * - All three auth modes coexist on the same /mcp endpoint
27
- */
28
- import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
29
- import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
30
- import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
31
- import { OAuthStateCodec } from './oauth-state.js';
32
- import { StatelessDcrClientStore } from './stateless-client-store.js';
33
- /** XSUAA credentials from VCAP_SERVICES */
34
- export interface XsuaaCredentials {
35
- url: string;
36
- clientid: string;
37
- clientsecret: string;
38
- xsappname: string;
39
- uaadomain: string;
40
- verificationkey?: string;
41
- }
42
- /**
43
- * Verify a JWT token using @sap/xssec.
44
- *
45
- * Creates a security context from the token using the XSUAA service,
46
- * then maps it to the MCP SDK's AuthInfo format.
47
- */
48
- export declare function createXsuaaTokenVerifier(credentials: XsuaaCredentials): (token: string) => Promise<AuthInfo>;
49
- /**
50
- * OIDC/UAA scopes that must NOT be prefixed with the app's xsappname. They are
51
- * reserved/global in XSUAA, so qualifying them (e.g. `openid` →
52
- * `arc1-mcp!t498139.openid`) produces an invalid scope that XSUAA rejects.
53
- */
54
- export declare const RESERVED_OAUTH_SCOPES: Set<string>;
55
- /**
56
- * Qualify short MCP scope names (`read`, `write`, `admin`, …) with the XSUAA
57
- * xsappname prefix XSUAA requires (it rejects a bare `admin`). Scopes that are
58
- * already qualified (contain a `.`, e.g. `uaa.user`) or are reserved OIDC scopes
59
- * ({@link RESERVED_OAUTH_SCOPES}) pass through untouched. Empty entries (Copilot
60
- * Studio sends `scope=""` → `[""]`) are dropped.
61
- */
62
- export declare function qualifyXsuaaScopes(scopes: string[], xsappname: string): string[];
63
- /**
64
- * Create a token verifier that chains multiple auth methods.
65
- *
66
- * Tries in order:
67
- * 1. XSUAA (@sap/xssec) — if XSUAA credentials are available
68
- * 2. Entra ID OIDC (jose) — if SAP_OIDC_ISSUER is configured
69
- * 3. API Key — if ARC1_API_KEYS is configured
70
- */
71
- export declare function createChainedTokenVerifier(config: {
72
- apiKeys?: Array<{
73
- key: string;
74
- profile: string;
75
- }>;
76
- oidcIssuer?: string;
77
- oidcAudience?: string;
78
- }, xsuaaVerifier?: (token: string) => Promise<AuthInfo>, oidcVerifier?: (token: string) => Promise<AuthInfo>): (token: string) => Promise<AuthInfo>;
79
- /**
80
- * Create a ProxyOAuthServerProvider that proxies OAuth to XSUAA.
81
- */
82
- /**
83
- * XSUAA-proxying OAuth provider.
84
- *
85
- * Extends ProxyOAuthServerProvider to replace the MCP client's local client_id
86
- * with the XSUAA service binding client_id when forwarding to XSUAA.
87
- *
88
- * Problem: MCP clients register via DCR and get a local client_id (e.g., "arc1-f63afbab").
89
- * But XSUAA only knows about its own client_id ("sb-arc1-mcp!t498139").
90
- * The standard ProxyOAuthServerProvider forwards the local client_id to XSUAA, which rejects it.
91
- *
92
- * Solution: Override authorize() to swap the client_id and use a custom fetch() for
93
- * the token exchange to inject the XSUAA credentials.
94
- */
95
- export declare class XsuaaProxyOAuthProvider extends ProxyOAuthServerProvider {
96
- private xsuaaClientId;
97
- private xsuaaClientSecret;
98
- private xsuaaTokenUrl;
99
- private xsuaaAuthUrl;
100
- private xsuaaXsappname;
101
- private _localClientStore;
102
- /** ARC-1's own callback URL, sent to XSUAA as the redirect_uri so ARC-1
103
- * sits in the return path and can re-encode the client's `state`
104
- * correctly (issue #214 — XSUAA emits literal `+`). */
105
- private callbackUrl;
106
- /** Signs/verifies the opaque, URL-safe state token sent to XSUAA. */
107
- private stateCodec;
108
- constructor(credentials: XsuaaCredentials, verifier: (token: string) => Promise<AuthInfo>, localClientStore: StatelessDcrClientStore, callbackUrl: string, stateCodec: OAuthStateCodec);
109
- /**
110
- * Override clientsStore to expose registerClient for DCR.
111
- * The MCP SDK checks this to decide whether to advertise
112
- * registration_endpoint in OAuth metadata and handle POST /register.
113
- */
114
- get clientsStore(): StatelessDcrClientStore;
115
- /**
116
- * Override authorize to replace the MCP client's local client_id
117
- * with the XSUAA service binding client_id.
118
- */
119
- authorize(_client: OAuthClientInformationFull, params: {
120
- state?: string;
121
- scopes?: string[];
122
- codeChallenge: string;
123
- redirectUri: string;
124
- resource?: URL;
125
- }, res: {
126
- redirect(url: string): void;
127
- }): Promise<void>;
128
- /**
129
- * Override exchangeAuthorizationCode to use XSUAA credentials
130
- * instead of the local DCR client credentials.
131
- */
132
- exchangeAuthorizationCode(_client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, _redirectUri?: string): Promise<{
133
- access_token: string;
134
- token_type: string;
135
- expires_in: number | undefined;
136
- refresh_token: string | undefined;
137
- scope: string | undefined;
138
- }>;
139
- /**
140
- * Override exchangeRefreshToken to use XSUAA credentials.
141
- */
142
- exchangeRefreshToken(_client: OAuthClientInformationFull, refreshToken: string, _scopes?: string[]): Promise<{
143
- access_token: string;
144
- token_type: string;
145
- expires_in: number | undefined;
146
- refresh_token: string | undefined;
147
- scope: string | undefined;
148
- }>;
149
- /**
150
- * Override revokeToken to use XSUAA service credentials consistently.
151
- * Without this override, the base class would attempt revocation with
152
- * the local client credentials, which don't match the XSUAA binding.
153
- *
154
- * Declared as a property (arrow function) to match the base class declaration.
155
- */
156
- revokeToken: (_client: OAuthClientInformationFull, request: {
157
- token: string;
158
- token_type_hint?: string;
159
- }) => Promise<void>;
160
- }
161
- export interface CreateXsuaaOAuthProviderOptions {
162
- /** Lifetime of issued DCR client_ids in seconds. Falls back to the store's
163
- * built-in default (30 days) when omitted. `0` disables expiration. */
164
- dcrTtlSeconds?: number;
165
- /**
166
- * Optional dedicated secret for HMAC-signing DCR client_ids. When set, the
167
- * DCR signing key derives from this secret instead of the XSUAA
168
- * `clientsecret`. Use this to keep cached client_ids valid across
169
- * `cf deploy` (which recreates the XSUAA binding and rotates its
170
- * clientsecret). Omit to fall back to the XSUAA clientsecret (legacy
171
- * behavior).
172
- */
173
- dcrSigningSecret?: string;
174
- /**
175
- * ARC-1's own OAuth callback URL (e.g. `https://app.../oauth/callback`),
176
- * sent to XSUAA as the redirect_uri so ARC-1 sits in the return path and
177
- * can fix XSUAA's `+`-in-state encoding bug (issue #214). Must be
178
- * absolute and match an xs-security.json `redirect-uris` pattern. When
179
- * omitted, falls back to `${appUrl}/oauth/callback`.
180
- */
181
- callbackUrl?: string;
182
- }
183
- export declare function createXsuaaOAuthProvider(credentials: XsuaaCredentials, appUrl: string, options?: CreateXsuaaOAuthProviderOptions): {
184
- provider: ProxyOAuthServerProvider;
185
- clientStore: StatelessDcrClientStore;
186
- stateCodec: OAuthStateCodec;
187
- };
188
- //# sourceMappingURL=xsuaa.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"xsuaa.d.ts","sourceRoot":"","sources":["../../src/server/xsuaa.ts"],"names":[],"mappings":"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"}
@@ -1,464 +0,0 @@
1
- /**
2
- * XSUAA OAuth proxy for MCP-native clients.
3
- *
4
- * Enables Claude Desktop, Cursor, VS Code, and MCP Inspector to authenticate
5
- * via BTP XSUAA using the MCP specification's OAuth discovery (RFC 8414).
6
- *
7
- * Uses the MCP SDK's ProxyOAuthServerProvider to delegate the OAuth flow
8
- * to XSUAA, and @sap/xssec for SAP-specific JWT validation.
9
- *
10
- * Design decisions:
11
- *
12
- * 1. @sap/xssec for token validation (not jose):
13
- * - SAP-specific x5t thumbprint and proof-of-possession validation
14
- * - Proper XSUAA audience format handling
15
- * - Offline validation with automatic JWKS caching
16
- * - checkLocalScope() for scope enforcement
17
- *
18
- * 2. Stateless DCR client store (StatelessDcrClientStore):
19
- * - MCP clients (Claude Desktop, Cursor) register dynamically via RFC 7591
20
- * - client_ids are HMAC-signed by the XSUAA clientsecret, so they
21
- * survive restarts / pushes / cell moves without any backing store
22
- * - XSUAA clientId is pre-registered as the default client
23
- *
24
- * 3. Chained token verifier:
25
- * - Tries XSUAA → Entra ID OIDC → API key in order
26
- * - All three auth modes coexist on the same /mcp endpoint
27
- */
28
- import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
29
- import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
30
- import { XsuaaService } from '@sap/xssec';
31
- import { expandScopes } from '../authz/policy.js';
32
- import { API_KEY_PROFILES } from './config.js';
33
- import { logger } from './logger.js';
34
- import { OAuthStateCodec } from './oauth-state.js';
35
- import { StatelessDcrClientStore } from './stateless-client-store.js';
36
- // ─── XSUAA Token Verifier ────────────────────────────────────────────
37
- /**
38
- * Verify a JWT token using @sap/xssec.
39
- *
40
- * Creates a security context from the token using the XSUAA service,
41
- * then maps it to the MCP SDK's AuthInfo format.
42
- */
43
- export function createXsuaaTokenVerifier(credentials) {
44
- const xsuaaService = new XsuaaService({
45
- clientid: credentials.clientid,
46
- clientsecret: credentials.clientsecret,
47
- url: credentials.url,
48
- xsappname: credentials.xsappname,
49
- uaadomain: credentials.uaadomain,
50
- });
51
- return async (token) => {
52
- logger.debug('XSUAA token verification: creating security context');
53
- const securityContext = await xsuaaService.createSecurityContext(token, { jwt: token });
54
- // Extract scopes (remove xsappname prefix for local scope names)
55
- const grantedScopes = [];
56
- // The token contains scopes like "arc1-mcp!b12345.read"
57
- // checkLocalScope strips the prefix for us
58
- for (const scope of ['read', 'write', 'data', 'sql', 'transports', 'git', 'admin']) {
59
- if (securityContext.checkLocalScope(scope)) {
60
- grantedScopes.push(scope);
61
- }
62
- }
63
- // Apply implied scope expansion: admin→all, write→read, sql→data
64
- const expandedScopes = expandScopes(grantedScopes);
65
- const expiresAt = securityContext.token?.payload?.exp;
66
- const authInfo = {
67
- token,
68
- clientId: securityContext.getClientId(),
69
- scopes: expandedScopes,
70
- expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined,
71
- extra: {
72
- userName: securityContext.getLogonName?.() ?? undefined,
73
- email: securityContext.getEmail?.() ?? undefined,
74
- },
75
- };
76
- logger.debug('XSUAA token verified', {
77
- clientId: authInfo.clientId,
78
- scopes: expandedScopes,
79
- userName: authInfo.extra.userName,
80
- email: authInfo.extra.email,
81
- });
82
- return authInfo;
83
- };
84
- }
85
- // ─── API Key Matching Helper ─────────────────────────────────────────
86
- /**
87
- * Match a token against configured API keys (multi-key or single).
88
- * Used by both the chained verifier (XSUAA mode) and standard verifier.
89
- */
90
- function matchApiKeyFromConfig(config, token) {
91
- if (config.apiKeys) {
92
- for (const entry of config.apiKeys) {
93
- if (token === entry.key) {
94
- const profile = API_KEY_PROFILES[entry.profile];
95
- if (!profile)
96
- return undefined;
97
- const scopes = expandScopes(profile.scopes);
98
- return { scopes, clientId: `api-key:${entry.profile}` };
99
- }
100
- }
101
- }
102
- return undefined;
103
- }
104
- // ─── Chained Token Verifier ──────────────────────────────────────────
105
- /**
106
- * OIDC/UAA scopes that must NOT be prefixed with the app's xsappname. They are
107
- * reserved/global in XSUAA, so qualifying them (e.g. `openid` →
108
- * `arc1-mcp!t498139.openid`) produces an invalid scope that XSUAA rejects.
109
- */
110
- export const RESERVED_OAUTH_SCOPES = new Set(['openid', 'profile', 'email', 'offline_access']);
111
- /**
112
- * Qualify short MCP scope names (`read`, `write`, `admin`, …) with the XSUAA
113
- * xsappname prefix XSUAA requires (it rejects a bare `admin`). Scopes that are
114
- * already qualified (contain a `.`, e.g. `uaa.user`) or are reserved OIDC scopes
115
- * ({@link RESERVED_OAUTH_SCOPES}) pass through untouched. Empty entries (Copilot
116
- * Studio sends `scope=""` → `[""]`) are dropped.
117
- */
118
- export function qualifyXsuaaScopes(scopes, xsappname) {
119
- return scopes
120
- .filter((s) => s.length > 0)
121
- .map((s) => (s.includes('.') || RESERVED_OAUTH_SCOPES.has(s) ? s : `${xsappname}.${s}`));
122
- }
123
- /**
124
- * Create a token verifier that chains multiple auth methods.
125
- *
126
- * Tries in order:
127
- * 1. XSUAA (@sap/xssec) — if XSUAA credentials are available
128
- * 2. Entra ID OIDC (jose) — if SAP_OIDC_ISSUER is configured
129
- * 3. API Key — if ARC1_API_KEYS is configured
130
- */
131
- export function createChainedTokenVerifier(config, xsuaaVerifier, oidcVerifier) {
132
- return async (token) => {
133
- const tokenPreview = `${token.slice(0, 20)}...${token.slice(-10)}`;
134
- logger.debug('Chained token verifier: starting', { tokenPreview });
135
- // 1. Try XSUAA
136
- if (xsuaaVerifier) {
137
- try {
138
- const result = await xsuaaVerifier(token);
139
- logger.debug('Chained token verifier: XSUAA succeeded', {
140
- clientId: result.clientId,
141
- scopes: result.scopes,
142
- user: result.extra?.email || result.extra?.userName,
143
- });
144
- return result;
145
- }
146
- catch (err) {
147
- logger.debug('Chained token verifier: XSUAA failed, trying next', {
148
- error: err instanceof Error ? err.message : String(err),
149
- });
150
- }
151
- }
152
- // 2. Try Entra ID OIDC
153
- if (oidcVerifier) {
154
- try {
155
- const result = await oidcVerifier(token);
156
- logger.debug('Chained token verifier: OIDC succeeded', {
157
- clientId: result.clientId,
158
- scopes: result.scopes,
159
- });
160
- return result;
161
- }
162
- catch (err) {
163
- logger.debug('Chained token verifier: OIDC failed, trying next', {
164
- error: err instanceof Error ? err.message : String(err),
165
- });
166
- }
167
- }
168
- // 3. Try API key (multi-key with profiles)
169
- const apiKeyMatch = matchApiKeyFromConfig(config, token);
170
- if (apiKeyMatch) {
171
- logger.debug('Chained token verifier: API key matched', { clientId: apiKeyMatch.clientId });
172
- return {
173
- token,
174
- clientId: apiKeyMatch.clientId,
175
- scopes: apiKeyMatch.scopes,
176
- // MCP SDK's requireBearerAuth requires expiresAt — set to 1 year
177
- expiresAt: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60,
178
- extra: {},
179
- };
180
- }
181
- logger.debug('Chained token verifier: all methods failed', { tokenPreview });
182
- throw new InvalidTokenError('Token validation failed: not a valid XSUAA, OIDC, or API key token');
183
- };
184
- }
185
- // ─── OAuth Provider Factory ──────────────────────────────────────────
186
- /**
187
- * Create a ProxyOAuthServerProvider that proxies OAuth to XSUAA.
188
- */
189
- /**
190
- * XSUAA-proxying OAuth provider.
191
- *
192
- * Extends ProxyOAuthServerProvider to replace the MCP client's local client_id
193
- * with the XSUAA service binding client_id when forwarding to XSUAA.
194
- *
195
- * Problem: MCP clients register via DCR and get a local client_id (e.g., "arc1-f63afbab").
196
- * But XSUAA only knows about its own client_id ("sb-arc1-mcp!t498139").
197
- * The standard ProxyOAuthServerProvider forwards the local client_id to XSUAA, which rejects it.
198
- *
199
- * Solution: Override authorize() to swap the client_id and use a custom fetch() for
200
- * the token exchange to inject the XSUAA credentials.
201
- */
202
- export class XsuaaProxyOAuthProvider extends ProxyOAuthServerProvider {
203
- xsuaaClientId;
204
- xsuaaClientSecret;
205
- xsuaaTokenUrl;
206
- xsuaaAuthUrl;
207
- xsuaaXsappname;
208
- _localClientStore;
209
- /** ARC-1's own callback URL, sent to XSUAA as the redirect_uri so ARC-1
210
- * sits in the return path and can re-encode the client's `state`
211
- * correctly (issue #214 — XSUAA emits literal `+`). */
212
- callbackUrl;
213
- /** Signs/verifies the opaque, URL-safe state token sent to XSUAA. */
214
- stateCodec;
215
- constructor(credentials, verifier, localClientStore, callbackUrl, stateCodec) {
216
- const authUrl = `${credentials.url}/oauth/authorize`;
217
- const tokenUrl = `${credentials.url}/oauth/token`;
218
- super({
219
- endpoints: {
220
- authorizationUrl: authUrl,
221
- tokenUrl: tokenUrl,
222
- revocationUrl: `${credentials.url}/oauth/revoke`,
223
- },
224
- verifyAccessToken: verifier,
225
- getClient: (clientId) => localClientStore.getClient(clientId),
226
- });
227
- this.xsuaaClientId = credentials.clientid;
228
- this.xsuaaClientSecret = credentials.clientsecret;
229
- this.xsuaaTokenUrl = tokenUrl;
230
- this.xsuaaAuthUrl = authUrl;
231
- this.xsuaaXsappname = credentials.xsappname;
232
- this._localClientStore = localClientStore;
233
- this.callbackUrl = callbackUrl;
234
- this.stateCodec = stateCodec;
235
- this.skipLocalPkceValidation = true;
236
- }
237
- /**
238
- * Override clientsStore to expose registerClient for DCR.
239
- * The MCP SDK checks this to decide whether to advertise
240
- * registration_endpoint in OAuth metadata and handle POST /register.
241
- */
242
- get clientsStore() {
243
- return this._localClientStore;
244
- }
245
- /**
246
- * Override authorize to replace the MCP client's local client_id
247
- * with the XSUAA service binding client_id.
248
- */
249
- async authorize(_client, params, res) {
250
- // ── Callback proxy (issue #214) ──────────────────────────────────
251
- // Instead of sending XSUAA the client's redirect_uri and the client's
252
- // raw `state`, we send XSUAA ARC-1's OWN /oauth/callback and an opaque,
253
- // URL-safe state token that carries the client's real redirect_uri +
254
- // state. XSUAA then redirects back to ARC-1 (not the client), and the
255
- // /oauth/callback route re-emits the client's ORIGINAL state with proper
256
- // `%2B` encoding. This sidesteps XSUAA's bug of echoing a literal `+`
257
- // for any state containing `+` (standard base64 states hit this ~50% of
258
- // the time; VS Code surfaces it as "State does not match").
259
- //
260
- // The token is base64url (no `+`/`/`), so XSUAA has nothing to mangle on
261
- // the round trip and Express's `+`→space decode is a no-op on it.
262
- //
263
- // WORKAROUND removal condition + upstream tracking (XSUAA root cause,
264
- // arc-1#214, vscode#314715) are documented at the top of oauth-state.ts.
265
- const arc1State = this.stateCodec.encode({
266
- clientState: params.state,
267
- clientRedirectUri: params.redirectUri,
268
- clientId: _client.client_id,
269
- });
270
- const targetUrl = new URL(this.xsuaaAuthUrl);
271
- const searchParams = new URLSearchParams({
272
- client_id: this.xsuaaClientId, // Use XSUAA client, not local DCR client
273
- response_type: 'code',
274
- redirect_uri: this.callbackUrl, // ARC-1's callback, not the client's
275
- code_challenge: params.codeChallenge, // client's PKCE challenge, forwarded as-is
276
- code_challenge_method: 'S256',
277
- state: arc1State,
278
- });
279
- if (params.scopes?.length) {
280
- // Qualify short MCP scopes (read, write, admin) with the xsappname prefix
281
- // XSUAA requires, while leaving reserved OIDC scopes (openid, …) alone.
282
- const qualifiedScopes = qualifyXsuaaScopes(params.scopes, this.xsuaaXsappname);
283
- if (qualifiedScopes.length > 0) {
284
- searchParams.set('scope', qualifiedScopes.join(' '));
285
- }
286
- }
287
- if (params.resource)
288
- searchParams.set('resource', params.resource.toString());
289
- targetUrl.search = searchParams.toString();
290
- logger.debug('XSUAA authorize redirect (callback proxy)', {
291
- xsuaaClient: this.xsuaaClientId,
292
- clientRedirectUri: params.redirectUri,
293
- callbackUrl: this.callbackUrl,
294
- });
295
- res.redirect(targetUrl.toString());
296
- }
297
- /**
298
- * Override exchangeAuthorizationCode to use XSUAA credentials
299
- * instead of the local DCR client credentials.
300
- */
301
- async exchangeAuthorizationCode(_client, authorizationCode, codeVerifier, _redirectUri) {
302
- logger.debug('XSUAA token exchange: authorization_code', {
303
- hasCodeVerifier: !!codeVerifier,
304
- });
305
- const params = new URLSearchParams({
306
- grant_type: 'authorization_code',
307
- code: authorizationCode,
308
- client_id: this.xsuaaClientId,
309
- client_secret: this.xsuaaClientSecret,
310
- });
311
- if (codeVerifier)
312
- params.set('code_verifier', codeVerifier);
313
- // OAuth requires the token-exchange redirect_uri to match the one sent at
314
- // authorize time. Since the callback proxy sent XSUAA ARC-1's own
315
- // /oauth/callback (not the client's redirect_uri), the exchange must use
316
- // the same value. The client's redirect_uri (_redirectUri) is irrelevant
317
- // to XSUAA here — XSUAA only ever saw ARC-1's callback.
318
- params.set('redirect_uri', this.callbackUrl);
319
- const response = await fetch(this.xsuaaTokenUrl, {
320
- method: 'POST',
321
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
322
- body: params.toString(),
323
- });
324
- if (!response.ok) {
325
- const text = await response.text();
326
- logger.error('XSUAA token exchange failed', { status: response.status, body: text.slice(0, 200) });
327
- throw new Error(`XSUAA token exchange failed: ${response.status}`);
328
- }
329
- const data = (await response.json());
330
- logger.debug('XSUAA token exchange: success', {
331
- tokenType: data.token_type,
332
- expiresIn: data.expires_in,
333
- hasRefreshToken: !!data.refresh_token,
334
- scope: data.scope,
335
- });
336
- return {
337
- access_token: data.access_token,
338
- token_type: data.token_type ?? 'bearer',
339
- expires_in: data.expires_in,
340
- refresh_token: data.refresh_token,
341
- scope: data.scope,
342
- };
343
- }
344
- /**
345
- * Override exchangeRefreshToken to use XSUAA credentials.
346
- */
347
- async exchangeRefreshToken(_client, refreshToken, _scopes) {
348
- const params = new URLSearchParams({
349
- grant_type: 'refresh_token',
350
- refresh_token: refreshToken,
351
- client_id: this.xsuaaClientId,
352
- client_secret: this.xsuaaClientSecret,
353
- });
354
- const response = await fetch(this.xsuaaTokenUrl, {
355
- method: 'POST',
356
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
357
- body: params.toString(),
358
- });
359
- if (!response.ok) {
360
- throw new Error(`XSUAA refresh token exchange failed: ${response.status}`);
361
- }
362
- const data = (await response.json());
363
- return {
364
- access_token: data.access_token,
365
- token_type: data.token_type ?? 'bearer',
366
- expires_in: data.expires_in,
367
- refresh_token: data.refresh_token,
368
- scope: data.scope,
369
- };
370
- }
371
- /**
372
- * Override revokeToken to use XSUAA service credentials consistently.
373
- * Without this override, the base class would attempt revocation with
374
- * the local client credentials, which don't match the XSUAA binding.
375
- *
376
- * Declared as a property (arrow function) to match the base class declaration.
377
- */
378
- revokeToken = async (_client, request) => {
379
- const revokeUrl = this.xsuaaTokenUrl.replace('/oauth/token', '/oauth/revoke');
380
- const params = new URLSearchParams({ token: request.token });
381
- if (request.token_type_hint) {
382
- params.set('token_type_hint', request.token_type_hint);
383
- }
384
- try {
385
- const response = await fetch(revokeUrl, {
386
- method: 'POST',
387
- headers: {
388
- 'Content-Type': 'application/x-www-form-urlencoded',
389
- Authorization: `Basic ${Buffer.from(`${this.xsuaaClientId}:${this.xsuaaClientSecret}`).toString('base64')}`,
390
- },
391
- body: params.toString(),
392
- });
393
- if (!response.ok) {
394
- logger.warn('XSUAA token revocation failed', { status: response.status, url: revokeUrl });
395
- }
396
- else {
397
- logger.debug('XSUAA token revoked successfully');
398
- }
399
- }
400
- catch (err) {
401
- logger.warn('XSUAA token revocation error', {
402
- error: err instanceof Error ? err.message : String(err),
403
- });
404
- }
405
- };
406
- }
407
- export function createXsuaaOAuthProvider(credentials, appUrl, options = {}) {
408
- // The signing secret defaults to the XSUAA `clientsecret`, which is the
409
- // trust anchor for "this server can mint client_ids". The downside: MTA
410
- // `cf deploy` recreates the service binding and rotates the clientsecret
411
- // — every redeploy invalidates every cached client_id. To opt out, pass a
412
- // dedicated secret via `dcrSigningSecret` (typically `ARC1_DCR_SIGNING_SECRET`
413
- // set with `cf set-env`, which survives `cf deploy`). Re-setting it
414
- // doubles as the explicit revocation knob.
415
- //
416
- // Empty / whitespace-only input falls back to the XSUAA `clientsecret`
417
- // (legacy mode) with a warning instead of crashing — `??` only falls back
418
- // on null/undefined, so an empty env var would otherwise reach the store
419
- // constructor's non-empty guard and kill startup. Compute the
420
- // dcrSigningSource label from the effective secret, not the raw input, so
421
- // it accurately reflects what's actually in use.
422
- const trimmedDcrSecret = options.dcrSigningSecret?.trim();
423
- let dcrSigningSecret;
424
- let dcrSigningSource;
425
- if (trimmedDcrSecret) {
426
- dcrSigningSecret = trimmedDcrSecret;
427
- dcrSigningSource = 'env';
428
- }
429
- else {
430
- if (options.dcrSigningSecret !== undefined) {
431
- logger.warn('ARC1_DCR_SIGNING_SECRET was set but is empty or whitespace-only — falling back to XSUAA clientsecret. Set a real secret with `openssl rand -base64 48` or unset the env var.');
432
- }
433
- dcrSigningSecret = credentials.clientsecret;
434
- dcrSigningSource = 'xsuaa';
435
- }
436
- const clientStore = new StatelessDcrClientStore(credentials.clientid, credentials.clientsecret, dcrSigningSecret, {
437
- ttlSeconds: options.dcrTtlSeconds,
438
- });
439
- const verifier = createXsuaaTokenVerifier(credentials);
440
- // The state codec reuses the same resolved signing secret as DCR (distinct
441
- // KDF label inside OAuthStateCodec keeps the two key spaces separate), so it
442
- // inherits the same "survives cf deploy" property when ARC1_DCR_SIGNING_SECRET
443
- // is set. State tokens are short-lived (single OAuth flow), so the codec uses
444
- // its own built-in TTL rather than the DCR TTL.
445
- const stateCodec = new OAuthStateCodec(dcrSigningSecret);
446
- const callbackUrl = options.callbackUrl ?? `${appUrl.replace(/\/$/, '')}/oauth/callback`;
447
- const provider = new XsuaaProxyOAuthProvider(credentials, verifier, clientStore, callbackUrl, stateCodec);
448
- logger.info('XSUAA OAuth provider created (stateless DCR + callback proxy)', {
449
- xsappname: credentials.xsappname,
450
- authorizationUrl: `${credentials.url}/oauth/authorize`,
451
- appUrl,
452
- callbackUrl,
453
- dcrTtlSeconds: options.dcrTtlSeconds,
454
- dcrSigningSource,
455
- });
456
- if (dcrSigningSource === 'env') {
457
- logger.info('DCR signing key uses dedicated ARC1_DCR_SIGNING_SECRET — cached client_ids survive cf deploys that rotate the XSUAA clientsecret.');
458
- }
459
- if (options.dcrTtlSeconds !== undefined && options.dcrTtlSeconds <= 0) {
460
- logger.info('DCR client_id TTL is disabled (ARC1_OAUTH_DCR_TTL_SECONDS=0) — registrations never expire by time; revocation is via ARC1_DCR_SIGNING_SECRET rotation.');
461
- }
462
- return { provider, clientStore, stateCodec };
463
- }
464
- //# sourceMappingURL=xsuaa.js.map