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.
- package/README.md +26 -26
- package/dist/adt/config.d.ts +1 -1
- package/dist/adt/config.d.ts.map +1 -1
- package/dist/adt/http.d.ts +1 -1
- package/dist/adt/http.d.ts.map +1 -1
- package/dist/adt/http.js.map +1 -1
- package/dist/authz/policy.d.ts +6 -0
- package/dist/authz/policy.d.ts.map +1 -1
- package/dist/authz/policy.js +20 -0
- package/dist/authz/policy.js.map +1 -1
- package/dist/cli.js +21 -3
- package/dist/cli.js.map +1 -1
- package/dist/handlers/dispatch.d.ts +3 -0
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +71 -53
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/schemas.d.ts +4 -4
- package/dist/plugins/manifest-interpreter.d.ts +25 -0
- package/dist/plugins/manifest-interpreter.d.ts.map +1 -0
- package/dist/plugins/manifest-interpreter.js +124 -0
- package/dist/plugins/manifest-interpreter.js.map +1 -0
- package/dist/public/define-tool.d.ts +9 -0
- package/dist/public/define-tool.d.ts.map +1 -0
- package/dist/public/define-tool.js +25 -0
- package/dist/public/define-tool.js.map +1 -0
- package/dist/public/index.d.ts +9 -0
- package/dist/public/index.d.ts.map +1 -0
- package/dist/public/index.js +10 -0
- package/dist/public/index.js.map +1 -0
- package/dist/public/testing.d.ts +26 -0
- package/dist/public/testing.d.ts.map +1 -0
- package/dist/public/testing.js +39 -0
- package/dist/public/testing.js.map +1 -0
- package/dist/public/types.d.ts +87 -0
- package/dist/public/types.d.ts.map +1 -0
- package/dist/public/types.js +4 -0
- package/dist/public/types.js.map +1 -0
- package/dist/registry/tool-registry.d.ts +74 -0
- package/dist/registry/tool-registry.d.ts.map +1 -0
- package/dist/registry/tool-registry.js +59 -0
- package/dist/registry/tool-registry.js.map +1 -0
- package/dist/server/app-url.d.ts +31 -0
- package/dist/server/app-url.d.ts.map +1 -0
- package/dist/server/app-url.js +50 -0
- package/dist/server/app-url.js.map +1 -0
- package/dist/server/audit.d.ts +4 -0
- package/dist/server/audit.d.ts.map +1 -1
- package/dist/server/audit.js.map +1 -1
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +19 -0
- package/dist/server/config.js.map +1 -1
- package/dist/server/http.d.ts +15 -46
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +105 -375
- package/dist/server/http.js.map +1 -1
- package/dist/server/logger.d.ts +22 -0
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/logger.js +22 -0
- package/dist/server/logger.js.map +1 -1
- package/dist/server/plugin-loader.d.ts +19 -0
- package/dist/server/plugin-loader.d.ts.map +1 -0
- package/dist/server/plugin-loader.js +162 -0
- package/dist/server/plugin-loader.js.map +1 -0
- package/dist/server/safe-http-client.d.ts +38 -0
- package/dist/server/safe-http-client.d.ts.map +1 -0
- package/dist/server/safe-http-client.js +129 -0
- package/dist/server/safe-http-client.js.map +1 -0
- package/dist/server/server.d.ts +2 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +36 -7
- package/dist/server/server.js.map +1 -1
- package/dist/server/types.d.ts +8 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +2 -0
- package/dist/server/types.js.map +1 -1
- package/package.json +24 -8
- package/dist/adt/btp.d.ts +0 -140
- package/dist/adt/btp.d.ts.map +0 -1
- package/dist/adt/btp.js +0 -427
- package/dist/adt/btp.js.map +0 -1
- package/dist/server/oauth-state.d.ts +0 -92
- package/dist/server/oauth-state.d.ts.map +0 -1
- package/dist/server/oauth-state.js +0 -163
- package/dist/server/oauth-state.js.map +0 -1
- package/dist/server/stateless-client-store.d.ts +0 -173
- package/dist/server/stateless-client-store.d.ts.map +0 -1
- package/dist/server/stateless-client-store.js +0 -503
- package/dist/server/stateless-client-store.js.map +0 -1
- package/dist/server/xsuaa.d.ts +0 -188
- package/dist/server/xsuaa.d.ts.map +0 -1
- package/dist/server/xsuaa.js +0 -464
- package/dist/server/xsuaa.js.map +0 -1
package/dist/server/xsuaa.d.ts
DELETED
|
@@ -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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"}
|
package/dist/server/xsuaa.js
DELETED
|
@@ -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
|