Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ These config files will be used in tool configuration explained below.
| **Variable** | **Description** | **Default** | **Note** |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TRANSPORT` | The MCP transport type to use for the server. | `stdio` | Possible values are `stdio` or `http`. For `http`, see [HTTP Server Configuration](#http-server-configuration) below for additional variables. See [Transports][mcp-transport] for details. |
| `AUTH` | The authentication method to use by the server. | `pat` | Possible values are `pat` or `direct-trust`. See below sections for additional required variables depending on the desired method. |
| `AUTH` | The authentication method to use by the server. | `pat` | Possible values are `pat`, `direct-trust`, or `jwt`. See below sections for additional required variables depending on the desired method. |
| `DEFAULT_LOG_LEVEL` | The default logging level of the server. | `debug` | |
| `DATASOURCE_CREDENTIALS` | A JSON string that includes usernames and passwords for any datasources that require them. | Empty string | Format is provided in the [DATASOURCE_CREDENTIALS](#datasource_credentials) section below. |
| `DISABLE_LOG_MASKING` | Disable masking of credentials in logs. For debug purposes only. | `false` | |
Expand Down Expand Up @@ -255,6 +255,47 @@ additional user attributes to include on the JWT. The following is an example:
{ "region": "West" }
```

#### JWT Provider Configuration

When `AUTH` is `jwt`, before the MCP server authenticates to the Tableau REST API, it will make a
POST request to the endpoint provided in `JWT_PROVIDER_URL`. This endpoint must return the JSON web
token to then be used to authenticate to the REST API. It must only accept and return JSON.

POST request body:

```js
{
username: "[email protected]", // The value of JWT_SUB_CLAIM
scopes: ["tableau:example:scope"], // The list of scopes the JWT should have
source: "tableau-mcp",
resource: 'mcp-tool-name', // The name of the tool being called e.g. query-datasource
server: "https://tableau.example.com", // The value of SERVER
siteName: "siteName", // The value of SITE_NAME
}
```

Expected response:

```json
{
"jwt": "eyJhbGciOiJI..."
}
```

Example [Express][express] route handler:

```js
async function jwtProviderRouteHandler(req, res) {
const { username, scopes, source, resource, server, siteName } = req.body;
const userAttributes = { isAdmin: username === '[email protected]' };

// https://github.com/tableau/connected-apps-jwt-samples/blob/main/javascript/index.js
const jwt = generateJwt(userAttributes);

res.json({ jwt });
}
```

##### DATASOURCE_CREDENTIALS

The `DATASOURCE_CREDENTIALS` environment variable is a JSON string that includes usernames and
Expand Down
41 changes: 41 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('Config', () => {
CONNECTED_APP_SECRET_ID: undefined,
CONNECTED_APP_SECRET_VALUE: undefined,
JWT_ADDITIONAL_PAYLOAD: undefined,
JWT_PROVIDER_URL: undefined,
DATASOURCE_CREDENTIALS: undefined,
DEFAULT_LOG_LEVEL: undefined,
DISABLE_LOG_MASKING: undefined,
Expand Down Expand Up @@ -625,4 +626,44 @@ describe('Config', () => {
expect(config.jwtAdditionalPayload).toBe('{}');
});
});

describe('JWT auth configuration', () => {
it('should parse jwt auth configuration when all required variables are provided', () => {
process.env = {
...process.env,
...defaultEnvVars,
AUTH: 'jwt',
JWT_PROVIDER_URL: 'https://example.com/jwt',
JWT_SUB_CLAIM: '[email protected]',
};

const config = new Config();
expect(config.auth).toBe('jwt');
expect(config.jwtProviderUrl).toBe('https://example.com/jwt');
expect(config.jwtSubClaim).toBe('[email protected]');
});

it('should throw error when JWT_PROVIDER_URL is missing for jwt auth', () => {
process.env = {
...process.env,
...defaultEnvVars,
AUTH: 'jwt',
JWT_PROVIDER_URL: undefined,
};

expect(() => new Config()).toThrow('The environment variable JWT_PROVIDER_URL is not set');
});

it('should throw error when JWT_SUB_CLAIM is missing for jwt auth', () => {
process.env = {
...process.env,
...defaultEnvVars,
AUTH: 'jwt',
JWT_PROVIDER_URL: 'https://example.com',
JWT_SUB_CLAIM: undefined,
};

expect(() => new Config()).toThrow('The environment variable JWT_SUB_CLAIM is not set');
});
});
});
8 changes: 7 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolN
import { isTransport, TransportName } from './transports.js';
import invariant from './utils/invariant.js';

const authTypes = ['pat', 'direct-trust'] as const;
const authTypes = ['pat', 'direct-trust', 'jwt'] as const;
type AuthType = (typeof authTypes)[number];

export class Config {
Expand All @@ -23,6 +23,7 @@ export class Config {
connectedAppSecretId: string;
connectedAppSecretValue: string;
jwtAdditionalPayload: string;
jwtProviderUrl: string;
datasourceCredentials: string;
defaultLogLevel: string;
disableLogMasking: boolean;
Expand All @@ -49,6 +50,7 @@ export class Config {
CONNECTED_APP_SECRET_ID: secretId,
CONNECTED_APP_SECRET_VALUE: secretValue,
JWT_ADDITIONAL_PAYLOAD: jwtAdditionalPayload,
JWT_PROVIDER_URL: jwtProviderUrl,
DATASOURCE_CREDENTIALS: datasourceCredentials,
DEFAULT_LOG_LEVEL: defaultLogLevel,
DISABLE_LOG_MASKING: disableLogMasking,
Expand Down Expand Up @@ -107,6 +109,9 @@ export class Config {
invariant(clientId, 'The environment variable CONNECTED_APP_CLIENT_ID is not set');
invariant(secretId, 'The environment variable CONNECTED_APP_SECRET_ID is not set');
invariant(secretValue, 'The environment variable CONNECTED_APP_SECRET_VALUE is not set');
} else if (this.auth === 'jwt') {
invariant(jwtProviderUrl, 'The environment variable JWT_PROVIDER_URL is not set');
invariant(jwtSubClaim, 'The environment variable JWT_SUB_CLAIM is not set');
}

this.server = server;
Expand All @@ -117,6 +122,7 @@ export class Config {
this.connectedAppSecretId = secretId ?? '';
this.connectedAppSecretValue = secretValue ?? '';
this.jwtAdditionalPayload = jwtAdditionalPayload || '{}';
this.jwtProviderUrl = jwtProviderUrl ?? '';
}
}

Expand Down
93 changes: 92 additions & 1 deletion src/restApiInstance.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';

import { getConfig } from './config.js';
import { log } from './logging/log.js';
Expand Down Expand Up @@ -50,6 +50,7 @@ describe('restApiInstance', () => {
requestId: mockRequestId,
server: new Server(),
jwtScopes: [],
context: 'none',
callback: (restApi) => Promise.resolve(restApi),
});

Expand Down Expand Up @@ -233,4 +234,94 @@ describe('restApiInstance', () => {
);
});
});

describe('JWT auth', () => {
const fetchJsonResolve = vi.fn();

const mockJwtProviderResponses = vi.hoisted(() => ({
success: {
jwt: 'mock-jwt',
},
error: {
token: 'mock-jwt',
},
}));

const mocks = vi.hoisted(() => ({
mockJwtProviderResponse: vi.fn(),
}));

beforeEach(() => {
vi.spyOn(global, 'fetch').mockImplementation(
vi.fn(async () =>
Promise.resolve({
json: async () => {
const json = await mocks.mockJwtProviderResponse();
fetchJsonResolve(json);
return Promise.resolve(json);
},
}),
) as Mock,
);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should create a new RestApi instance and sign in', async () => {
mocks.mockJwtProviderResponse.mockResolvedValue(mockJwtProviderResponses.success);

const config = getConfig();
config.auth = 'jwt';
config.jwtProviderUrl = 'https://example.com/jwt';
config.jwtSubClaim = '[email protected]';

await useRestApi({
config,
requestId: mockRequestId,
server: new Server(),
jwtScopes: ['tableau:content:read'],
context: 'query-datasource',
callback: (restApi) => Promise.resolve(restApi),
});

expect(fetch).toHaveBeenCalledWith(config.jwtProviderUrl, {
method: 'POST',
body: JSON.stringify({
username: config.jwtSubClaim,
scopes: ['tableau:content:read'],
source: 'test-server',
resource: 'query-datasource',
server: 'https://my-tableau-server.com',
siteName: 'tc25',
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
expect(fetchJsonResolve).toHaveBeenCalledWith(mockJwtProviderResponses.success);
});

it('should throw an error if the JWT provider returns an invalid response', async () => {
mocks.mockJwtProviderResponse.mockResolvedValue(mockJwtProviderResponses.error);

const config = getConfig();
config.auth = 'jwt';
config.jwtProviderUrl = 'https://example.com/jwt';
config.jwtSubClaim = '[email protected]';

await expect(
useRestApi({
config,
requestId: mockRequestId,
server: new Server(),
jwtScopes: ['tableau:content:read'],
context: 'query-datasource',
callback: (restApi) => Promise.resolve(restApi),
}),
).rejects.toThrow('Invalid JWT response, expected: { "jwt": "..." }');
});
});
});
46 changes: 39 additions & 7 deletions src/restApiInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
} from './sdks/tableau/interceptors.js';
import RestApi from './sdks/tableau/restApi.js';
import { Server } from './server.js';
import { ToolName } from './tools/toolName.js';
import { getExceptionMessage } from './utils/getExceptionMessage.js';
import { getJwtFromProvider } from './utils/getJwtFromProvider.js';

type JwtScopes =
| 'tableau:viz_data_service:read'
Expand All @@ -27,12 +29,19 @@ type JwtScopes =
| 'tableau:insights:read'
| 'tableau:views:download';

const getNewRestApiInstanceAsync = async (
config: Config,
requestId: RequestId,
server: Server,
jwtScopes: Set<JwtScopes>,
): Promise<RestApi> => {
const getNewRestApiInstanceAsync = async ({
config,
requestId,
server,
jwtScopes,
context,
}: {
config: Config;
requestId: RequestId;
server: Server;
jwtScopes: Set<JwtScopes>;
context: ToolName | 'none';
}): Promise<RestApi> => {
const restApi = new RestApi(config.server, {
requestInterceptor: [
getRequestInterceptor(server, requestId),
Expand Down Expand Up @@ -62,6 +71,21 @@ const getNewRestApiInstanceAsync = async (
scopes: jwtScopes,
additionalPayload: getJwtAdditionalPayload(config),
});
} else if (config.auth === 'jwt') {
const jwt = await getJwtFromProvider(config.jwtProviderUrl, {
username: getJwtSubClaim(config),
scopes: [...jwtScopes],
source: server.name,
resource: context,
server: config.server,
siteName: config.siteName,
});

await restApi.signIn({
type: 'jwt',
siteName: config.siteName,
jwt,
});
}

return restApi;
Expand All @@ -73,14 +97,22 @@ export const useRestApi = async <T>({
server,
callback,
jwtScopes,
context,
}: {
config: Config;
requestId: RequestId;
server: Server;
jwtScopes: Array<JwtScopes>;
context: ToolName | 'none';
callback: (restApi: RestApi) => Promise<T>;
}): Promise<T> => {
const restApi = await getNewRestApiInstanceAsync(config, requestId, server, new Set(jwtScopes));
const restApi = await getNewRestApiInstanceAsync({
config,
requestId,
server,
jwtScopes: new Set(jwtScopes),
context,
});
try {
return await callback(restApi);
} finally {
Expand Down
8 changes: 8 additions & 0 deletions src/scripts/createClaudeDesktopExtensionManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ const envVars = {
required: false,
sensitive: false,
},
JWT_PROVIDER_URL: {
includeInUserConfig: false,
type: 'string',
title: 'JWT Provider URL',
description: 'The URL of the JWT provider.',
required: false,
sensitive: false,
},
TRANSPORT: {
includeInUserConfig: false,
type: 'string',
Expand Down
4 changes: 4 additions & 0 deletions src/sdks/tableau/authConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ export type AuthConfig = {
scopes: Set<string>;
additionalPayload?: Record<string, unknown>;
}
| {
type: 'jwt';
jwt: string;
}
);
4 changes: 4 additions & 0 deletions src/sdks/tableau/methods/authenticationMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export default class AuthenticationMethods extends Methods<typeof authentication
additionalPayload: authConfig.additionalPayload,
}),
};
case 'jwt':
return {
jwt: authConfig.jwt,
};
}
})()),
},
Expand Down
Loading