Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ coverage/
config.json
env.list
manifest.json
tableau-mcp.dxt
tableau-mcp.dxt
*.pem
87 changes: 86 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-provider`. 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,91 @@ additional user attributes to include on the JWT. The following is an example:
{ "region": "West" }
```

#### JWT Provider Configuration

When `AUTH` is `jwt-provider`, 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.

The following environment variables are required:

| **Variable** | **Description** | **Notes** |
| ------------------------------ | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JWT_PROVIDER_URL` | The URL of the JWT provider endpoint. | Example: `https://example.com/jwt-provider` |
| `JWT_PROVIDER_SECRET` | The secret value to encrypt and provide on the secret header. | The JWT provider must decrypt the value of the secret header using the private key and verify it matches. See below for an example. |
| `JWT_PROVIDER_PUBLIC_KEY_PATH` | The absolute path to the RSA public key (.pem) file used to encrypt the JWT provider secret header. | Only PEM format is supported. If you need a key pair, you can generate them using [openssl-genrsa][genrsa] e.g. `openssl genrsa -out private.pem` and `openssl rsa -in private.pem -pubout -out public.pem` |
| `JWT_SUB_CLAIM` | The username provided as the `username` in the request body. | The JWT provider doesn't necessarilly need to use this, but had `AUTH` been `direct-trust`, it would have been used for the `sub` claim of the JWT generated by the Tableau MCP server. |

POST request header:

```
x-tabmcp-jwt-provider-secret: [Encrypted value of JWT_PROVIDER_SECRET]
```

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) {
// Read the secret header from the request
const secret = req.headers['x-tabmcp-jwt-provider-secret'];

// Decrypt the secret using the private key.
// The secret was encrypted by the Tableau MCP server using the public key.
const privateKey = crypto.createPrivateKey({
format: 'pem',
key: readFileSync(process.env.PRIVATE_KEY_PATH),
passphrase: process.env.PRIVATE_KEY_PASSPHRASE,
});

// compactDecrypt is a function from the jose package
// https://www.npmjs.com/package/jose
const { plaintext } = await compactDecrypt(secret, privateKey);
const equal = crypto.timingSafeEqual(
plaintext,
new TextEncoder().encode(process.env.JWT_PROVIDER_SECRET),
);

if (!equal) {
res.status(401).json({
error: 'Unauthorized',
});
return;
}

// Read the values provided by the Tableau MCP server
const { username, scopes, source, resource, server, siteName } = req.body;

// Add isAdmin user attribute for admin user
const userAttributes = { isAdmin: username === '[email protected]' };

// An example generateJwt function can be found here:
// 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
45 changes: 45 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ describe('Config', () => {
CONNECTED_APP_SECRET_ID: undefined,
CONNECTED_APP_SECRET_VALUE: undefined,
JWT_ADDITIONAL_PAYLOAD: undefined,
JWT_PROVIDER_URL: undefined,
JWT_PROVIDER_SECRET: undefined,
JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH: undefined,
DATASOURCE_CREDENTIALS: undefined,
DEFAULT_LOG_LEVEL: undefined,
DISABLE_LOG_MASKING: undefined,
Expand Down Expand Up @@ -625,4 +628,46 @@ 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-provider',
JWT_PROVIDER_URL: 'https://example.com/jwt',
JWT_PROVIDER_SECRET: 'secret',
JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH: 'public.pem',
JWT_SUB_CLAIM: '[email protected]',
};

const config = new Config();
expect(config.auth).toBe('jwt-provider');
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-provider',
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-provider',
JWT_PROVIDER_URL: 'https://example.com',
JWT_SUB_CLAIM: undefined,
};

expect(() => new Config()).toThrow('The environment variable JWT_SUB_CLAIM is not set');
});
});
});
47 changes: 46 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { CorsOptions } from 'cors';
import { createPublicKey } from 'crypto';
import { existsSync, readFileSync } from 'fs';
import { CompactEncrypt } from 'jose';

import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js';
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-provider'] as const;
type AuthType = (typeof authTypes)[number];

export class Config {
private readonly _jwtProviderSecretPublicKeyPath: string;
private readonly _jwtProviderSecret: string;

private _jwtProviderEncryptedSecret: string | undefined;

auth: AuthType;
server: string;
transport: TransportName;
Expand All @@ -23,6 +31,7 @@ export class Config {
connectedAppSecretId: string;
connectedAppSecretValue: string;
jwtAdditionalPayload: string;
jwtProviderUrl: string;
datasourceCredentials: string;
defaultLogLevel: string;
disableLogMasking: boolean;
Expand All @@ -49,6 +58,9 @@ export class Config {
CONNECTED_APP_SECRET_ID: secretId,
CONNECTED_APP_SECRET_VALUE: secretValue,
JWT_ADDITIONAL_PAYLOAD: jwtAdditionalPayload,
JWT_PROVIDER_URL: jwtProviderUrl,
JWT_PROVIDER_SECRET: jwtProviderSecret,
JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH: jwtProviderSecretPublicKeyPath,
DATASOURCE_CREDENTIALS: datasourceCredentials,
DEFAULT_LOG_LEVEL: defaultLogLevel,
DISABLE_LOG_MASKING: disableLogMasking,
Expand Down Expand Up @@ -107,6 +119,18 @@ 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-provider') {
invariant(jwtProviderUrl, 'The environment variable JWT_PROVIDER_URL is not set');
invariant(jwtSubClaim, 'The environment variable JWT_SUB_CLAIM is not set');
invariant(jwtProviderSecret, 'The environment variable JWT_PROVIDER_SECRET is not set');
invariant(
jwtProviderSecretPublicKeyPath,
'The environment variable JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH is not set',
);

if (process.env.TABLEAU_MCP_TEST !== 'true' && !existsSync(jwtProviderSecretPublicKeyPath)) {
throw new Error(`The file ${jwtProviderSecretPublicKeyPath} does not exist`);
}
}

this.server = server;
Expand All @@ -117,7 +141,28 @@ export class Config {
this.connectedAppSecretId = secretId ?? '';
this.connectedAppSecretValue = secretValue ?? '';
this.jwtAdditionalPayload = jwtAdditionalPayload || '{}';
this.jwtProviderUrl = jwtProviderUrl ?? '';
this._jwtProviderSecretPublicKeyPath = jwtProviderSecretPublicKeyPath ?? '';
this._jwtProviderSecret = jwtProviderSecret ?? '';
}

getJwtProviderEncryptedSecret = async (): Promise<string> => {
if (!this._jwtProviderEncryptedSecret) {
const publicKeyContents = readFileSync(this._jwtProviderSecretPublicKeyPath);
const publicKey = createPublicKey({
key: publicKeyContents,
format: 'pem',
});

this._jwtProviderEncryptedSecret = await new CompactEncrypt(
new TextEncoder().encode(this._jwtProviderSecret),
)
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' })
.encrypt(publicKey);
}

return this._jwtProviderEncryptedSecret;
};
}

function validateServer(server: string): void {
Expand Down
97 changes: 96 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,98 @@ 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({
ok: true,
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-provider';
config.jwtProviderUrl = 'https://example.com/jwt';
config.jwtSubClaim = '[email protected]';
config.getJwtProviderEncryptedSecret = vi.fn().mockResolvedValue('mock-encrypted-secret');

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',
'x-tabmcp-jwt-provider-secret': 'mock-encrypted-secret',
},
});
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-provider';
config.jwtProviderUrl = 'https://example.com/jwt';
config.jwtSubClaim = '[email protected]';
config.getJwtProviderEncryptedSecret = vi.fn().mockResolvedValue('mock-encrypted-secret');

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": "..." }');
});
});
});
Loading