Skip to content

Commit 0441043

Browse files
authored
Merge pull request #419 from HelpCode-ai/keysersoft/datev-token-basic-auth
fix(connectors): client_secret_basic for OAuth token exchange — DATEV (v0.3.5)
2 parents 5251209 + 7c3faf7 commit 0441043

10 files changed

Lines changed: 127 additions & 15 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anythingmcp",
3-
"version": "0.3.4",
3+
"version": "0.3.5",
44
"description": "Self-hosted MCP gateway for REST, SOAP/WSDL, GraphQL and SQL — turn any API into MCP tools for Claude, ChatGPT, Gemini, Copilot and Cursor. 30+ pre-built adapters, on-prem audit log, OAuth2/RBAC. Open source (AGPL-3.0).",
55
"private": true,
66
"license": "AGPL-3.0-only",

packages/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@anythingmcp/backend",
3-
"version": "0.3.4",
3+
"version": "0.3.5",
44
"description": "AnythingMCP — NestJS Backend + Dynamic MCP Server",
55
"private": true,
66
"license": "AGPL-3.0-only",

packages/backend/src/adapters/de/datev.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"clientSecret": "{{DATEV_CLIENT_SECRET}}",
1919
"authorizationUrl": "https://login.datev.de/openid/authorize",
2020
"tokenUrl": "https://api.datev.de/token",
21+
"tokenAuthMethod": "basic",
2122
"scopes": "datev:accounting:clients accounting:clients:read accounting:documents"
2223
},
2324
"headers": {

packages/backend/src/connectors/connectors.controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,9 @@ export class ConnectorsController {
722722
clientId,
723723
clientSecret,
724724
tokenUrl: tokenEndpoint,
725+
tokenAuthMethod: authConfig.tokenAuthMethod
726+
? String(authConfig.tokenAuthMethod)
727+
: undefined,
725728
createdAt: Date.now(),
726729
});
727730

packages/backend/src/connectors/engines/oauth2-token.service.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,21 @@ export class OAuth2TokenService {
150150
grant_type: 'refresh_token',
151151
refresh_token: refreshToken,
152152
};
153-
if (clientId) body.client_id = clientId;
154-
if (clientSecret) body.client_secret = clientSecret;
153+
const useBasic =
154+
authConfig.tokenAuthMethod === 'basic' ||
155+
authConfig.tokenAuthMethod === 'client_secret_basic';
156+
if (useBasic && clientId && clientSecret) {
157+
// client_secret_basic — credentials in the Authorization header.
158+
// DATEV and other confidential-client providers reject body creds.
159+
if (clientId) body.client_id = clientId;
160+
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString(
161+
'base64',
162+
);
163+
headers.Authorization = `Basic ${basic}`;
164+
} else {
165+
if (clientId) body.client_id = clientId;
166+
if (clientSecret) body.client_secret = clientSecret;
167+
}
155168
}
156169

157170
await assertSafeOutboundUrl(tokenUrl);

packages/backend/src/connectors/mcp-oauth-callback.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export class McpOAuthCallbackController {
6464
clientId: flow.clientId,
6565
clientSecret: flow.clientSecret,
6666
codeVerifier: flow.codeVerifier,
67+
tokenAuthMethod: flow.tokenAuthMethod,
6768
});
6869

6970
this.logger.log(
@@ -80,6 +81,7 @@ export class McpOAuthCallbackController {
8081
tokenUrl: flow.tokenUrl,
8182
clientId: flow.clientId,
8283
clientSecret: flow.clientSecret,
84+
tokenAuthMethod: flow.tokenAuthMethod,
8385
expiresIn: tokens.expiresIn,
8486
expiresAt: Date.now() + (tokens.expiresIn || 3600) * 1000,
8587
authorizedAt: new Date().toISOString(),
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { McpOAuthService } from './mcp-oauth.service';
2+
import axios from 'axios';
3+
4+
jest.mock('axios');
5+
// assertSafeOutboundUrl performs DNS/SSRF checks — stub it out for unit tests.
6+
jest.mock('../common/ssrf.util', () => ({
7+
assertSafeOutboundUrl: jest.fn().mockResolvedValue(undefined),
8+
}));
9+
10+
const mockedAxios = axios as jest.Mocked<typeof axios>;
11+
12+
describe('McpOAuthService.exchangeCodeForTokens client authentication', () => {
13+
let service: McpOAuthService;
14+
15+
beforeEach(() => {
16+
service = new McpOAuthService();
17+
mockedAxios.post.mockReset();
18+
mockedAxios.post.mockResolvedValue({
19+
data: { access_token: 'at', refresh_token: 'rt', expires_in: 3600 },
20+
} as any);
21+
});
22+
23+
const baseParams = {
24+
tokenUrl: 'https://sandbox-api.datev.de/token',
25+
code: 'authcode',
26+
redirectUri: 'https://cloud.anythingmcp.com/api/mcp-oauth/callback',
27+
clientId: 'cid',
28+
clientSecret: 'secret',
29+
codeVerifier: 'verifier',
30+
};
31+
32+
it('defaults to client_secret_post (credentials in body, no Basic header)', async () => {
33+
await service.exchangeCodeForTokens({ ...baseParams });
34+
35+
const [, body, config] = mockedAxios.post.mock.calls[0];
36+
expect(String(body)).toContain('client_secret=secret');
37+
expect((config as any).headers.Authorization).toBeUndefined();
38+
});
39+
40+
it('uses client_secret_basic when tokenAuthMethod=basic (header, not body)', async () => {
41+
await service.exchangeCodeForTokens({
42+
...baseParams,
43+
tokenAuthMethod: 'basic',
44+
});
45+
46+
const [, body, config] = mockedAxios.post.mock.calls[0];
47+
// Secret must NOT be in the body...
48+
expect(String(body)).not.toContain('client_secret=');
49+
// ...but in the Authorization header as base64(client_id:client_secret).
50+
const expected =
51+
'Basic ' + Buffer.from('cid:secret').toString('base64');
52+
expect((config as any).headers.Authorization).toBe(expected);
53+
// client_id still present in the body per RFC 6749.
54+
expect(String(body)).toContain('client_id=cid');
55+
});
56+
57+
it("treats 'client_secret_basic' as an alias for basic", async () => {
58+
await service.exchangeCodeForTokens({
59+
...baseParams,
60+
tokenAuthMethod: 'client_secret_basic',
61+
});
62+
const [, , config] = mockedAxios.post.mock.calls[0];
63+
expect((config as any).headers.Authorization).toMatch(/^Basic /);
64+
});
65+
});

packages/backend/src/connectors/mcp-oauth.service.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ interface PendingOAuthFlow {
2020
clientId: string;
2121
clientSecret?: string;
2222
tokenUrl: string;
23+
/**
24+
* How the client authenticates at the token endpoint:
25+
* - undefined / 'post' → client_id + client_secret in the request body
26+
* (RFC 6749 client_secret_post) — the historical default.
27+
* - 'basic' → HTTP Basic Authorization header
28+
* (client_secret_basic). Required by providers like DATEV that reject
29+
* body credentials with 401 invalid_client.
30+
*/
31+
tokenAuthMethod?: string;
2332
createdAt: number;
2433
}
2534

@@ -151,33 +160,52 @@ export class McpOAuthService {
151160
clientId: string;
152161
clientSecret?: string;
153162
codeVerifier: string;
163+
tokenAuthMethod?: string;
154164
}): Promise<{
155165
accessToken: string;
156166
refreshToken?: string;
157167
expiresIn?: number;
158168
}> {
169+
const useBasic =
170+
params.tokenAuthMethod === 'basic' ||
171+
params.tokenAuthMethod === 'client_secret_basic';
172+
159173
const body: Record<string, string> = {
160174
grant_type: 'authorization_code',
161175
code: params.code,
162176
redirect_uri: params.redirectUri,
163177
client_id: params.clientId,
164178
code_verifier: params.codeVerifier,
165179
};
166-
if (params.clientSecret) {
180+
181+
const headers: Record<string, string> = {
182+
'Content-Type': 'application/x-www-form-urlencoded',
183+
Accept: 'application/json',
184+
};
185+
186+
if (useBasic && params.clientSecret) {
187+
// client_secret_basic (RFC 6749 §2.3.1): credentials go in the
188+
// Authorization header, NOT the body. Providers like DATEV reject a
189+
// body-supplied client_secret for confidential clients with 401.
190+
const basic = Buffer.from(
191+
`${params.clientId}:${params.clientSecret}`,
192+
).toString('base64');
193+
headers.Authorization = `Basic ${basic}`;
194+
} else if (params.clientSecret) {
195+
// client_secret_post (default): credentials in the body.
167196
body.client_secret = params.clientSecret;
168197
}
169198

170-
this.logger.debug(`Exchanging auth code at ${params.tokenUrl}`);
199+
this.logger.debug(
200+
`Exchanging auth code at ${params.tokenUrl} (auth=${useBasic ? 'basic' : 'post'})`,
201+
);
171202

172203
await assertSafeOutboundUrl(params.tokenUrl);
173204
const response = await axios.post(
174205
params.tokenUrl,
175206
new URLSearchParams(body).toString(),
176207
{
177-
headers: {
178-
'Content-Type': 'application/x-www-form-urlencoded',
179-
'Accept': 'application/json',
180-
},
208+
headers,
181209
timeout: 10000,
182210
},
183211
);

packages/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@anythingmcp/frontend",
3-
"version": "0.3.4",
3+
"version": "0.3.5",
44
"description": "AnythingMCP — Next.js Admin UI",
55
"private": true,
66
"license": "AGPL-3.0-only",

0 commit comments

Comments
 (0)