Skip to content
Merged
17 changes: 14 additions & 3 deletions docs/docs/configuration/mcp-config/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,22 @@ The MCP transport type to use for the server.

### `SITE_NAME`

The target Tableau site for OAuth.
The target Tableau site for OAuth. The user must sign in to this site unless
[`OAUTH_LOCK_SITE`](#oauth_lock_site) is `false`.

- When [`AUTH`](#auth) is `oauth`, leaving this empty means any site will be supported, determined
by the site the user signed into when connecting to the MCP server.
<hr />

### `OAUTH_LOCK_SITE`

Whether to require the user to sign in to the site specified in [`SITE_NAME`](#site_name) when using
OAuth.

- Default: `true`
- When `true`, the user must sign in to the site specified in [`SITE_NAME`](#site_name) (or the
Default site if empty).
- If the user already has an active Tableau session in their browser for a different site, an
error will be returned.
- When `false`, the user can sign in to any site they can access.
<hr />

### `OAUTH_REDIRECT_URI`
Expand Down
5 changes: 5 additions & 0 deletions docs/src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
* bundles Infima by default. Infima is a CSS framework designed to
* work well for content-centric websites.
*/

table td,
table th {
vertical-align: top;
}
17 changes: 17 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe('Config', () => {
DANGEROUSLY_DISABLE_OAUTH: undefined,
OAUTH_ISSUER: undefined,
OAUTH_REDIRECT_URI: undefined,
OAUTH_LOCK_SITE: undefined,
OAUTH_JWE_PRIVATE_KEY: undefined,
OAUTH_JWE_PRIVATE_KEY_PATH: undefined,
OAUTH_JWE_PRIVATE_KEY_PASSPHRASE: undefined,
Expand Down Expand Up @@ -1084,6 +1085,7 @@ describe('Config', () => {
clientIdSecretPairs: null,
issuer: defaultOAuthEnvVars.OAUTH_ISSUER,
redirectUri: `${defaultOAuthEnvVars.OAUTH_ISSUER}/Callback`,
lockSite: true,
jwePrivateKey: '',
jwePrivateKeyPath: defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH,
jwePrivateKeyPassphrase: undefined,
Expand All @@ -1103,6 +1105,7 @@ describe('Config', () => {
issuer: '',
clientIdSecretPairs: null,
redirectUri: '',
lockSite: true,
jwePrivateKey: '',
jwePrivateKeyPath: '',
jwePrivateKeyPassphrase: undefined,
Expand Down Expand Up @@ -1160,6 +1163,20 @@ describe('Config', () => {
});
});

it('should set lockSite to the specified value when OAUTH_LOCK_SITE is set', () => {
process.env = {
...process.env,
...defaultOAuthEnvVars,
OAUTH_LOCK_SITE: 'false',
};

const config = new Config();
expect(config.oauth).toEqual({
...defaultOAuthConfig,
lockSite: false,
});
});

it('should set jwePrivateKey to the specified value when OAUTH_JWE_PRIVATE_KEY is set', () => {
process.env = {
...process.env,
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export class Config {
enabled: boolean;
issuer: string;
redirectUri: string;
lockSite: boolean;
jwePrivateKey: string;
jwePrivateKeyPath: string;
jwePrivateKeyPassphrase: string | undefined;
Expand Down Expand Up @@ -122,6 +123,7 @@ export class Config {
TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: tableauServerVersionCheckIntervalInHours,
DANGEROUSLY_DISABLE_OAUTH: disableOauth,
OAUTH_ISSUER: oauthIssuer,
OAUTH_LOCK_SITE: oauthLockSite,
OAUTH_JWE_PRIVATE_KEY: oauthJwePrivateKey,
OAUTH_JWE_PRIVATE_KEY_PATH: oauthJwePrivateKeyPath,
OAUTH_JWE_PRIVATE_KEY_PASSPHRASE: oauthJwePrivateKeyPassphrase,
Expand Down Expand Up @@ -193,6 +195,7 @@ export class Config {
enabled: disableOauthOverride ? false : !!oauthIssuer,
issuer: oauthIssuer ?? '',
redirectUri: redirectUri || (oauthIssuer ? `${oauthIssuer}/Callback` : ''),
lockSite: oauthLockSite !== 'false', // Site locking is enabled by default
jwePrivateKey: oauthJwePrivateKey ?? '',
jwePrivateKeyPath: oauthJwePrivateKeyPath ?? '',
jwePrivateKeyPassphrase: oauthJwePrivateKeyPassphrase || undefined,
Expand Down
8 changes: 8 additions & 0 deletions src/scripts/createClaudeMcpBundleManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,14 @@ const envVars = {
required: false,
sensitive: false,
},
OAUTH_LOCK_SITE: {
includeInUserConfig: false,
type: 'boolean',
title: 'OAuth Lock Site',
description: 'Whether to lock the site when using OAuth.',
required: false,
sensitive: false,
},
OAUTH_CLIENT_ID_SECRET_PAIRS: {
includeInUserConfig: false,
type: 'string',
Expand Down
42 changes: 41 additions & 1 deletion src/server/oauth/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,50 @@ export function authorize(
oauthUrl.searchParams.set('device_name', getDeviceName(redirect_uri, state ?? ''));
oauthUrl.searchParams.set('client_type', 'tableau-mcp');

res.redirect(oauthUrl.toString());
if (config.oauth.lockSite) {
// The "redirected" parameter is used by Tableau's OAuth controller to determine whether the user will be shown the site picker.
// When provided, the user will not be shown the site picker.
oauthUrl.searchParams.set('redirected', 'true');
}

const redirectUrl = await getOAuthRedirectUrl(oauthUrl, { lockSite: config.oauth.lockSite });
res.redirect(redirectUrl.toString());
});
}

async function getOAuthRedirectUrl(
initialOAuthUrl: URL,
{ lockSite }: { lockSite: boolean },
): Promise<URL> {
if (lockSite) {
// When the site is locked, Tableau does the right thing and never shows the site picker,
// regardless of whether the user already has an active Tableau session in their browser.
return initialOAuthUrl;
}

// When the site is not locked, Tableau does the right thing and shows the site picker, but only on Cloud.
// On Server, if the user does not have an active Tableau session in their browser,
// Tableau does not show the site picker.
// We can force it to by changing the path from #/signin to #/site.

try {
const response = await fetch(initialOAuthUrl, { redirect: 'manual' });
if (response.status === 302) {
// The response is a redirect to the Tableau OAuth login page.
// Force it to ultimately show the site picker by changing the path from #/signin to #/site.
const location = response.headers.get('location');
if (location?.startsWith('#/signin') || location?.startsWith('/#/signin')) {
const locationUrl = new URL(location.replace('#/signin', '#/site'), initialOAuthUrl.origin);
return locationUrl;
}
}
} catch {
return initialOAuthUrl;
}

return initialOAuthUrl;
}

// https://client.dev/servers
async function getClientFromMetadataDoc(
clientMetadataUrl: URL,
Expand Down
34 changes: 30 additions & 4 deletions src/server/oauth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,18 @@ export function callback(
const { error, code, state } = result.data;

if (error) {
res.status(400).json({
error: 'access_denied',
error_description: 'User denied authorization',
});
if (error === 'invalid_request') {
res.status(400).json({
error: 'invalid_request',
error_description: `Invalid request. Did you sign into the wrong site? Expected: ${config.siteName || 'Default site'}`,
});
} else {
res.status(400).json({
error: 'access_denied',
error_description: 'User denied authorization',
});
}

return;
}

Expand Down Expand Up @@ -114,6 +122,24 @@ export function callback(
return;
}

if (
config.oauth.lockSite &&
sessionResult.value.site.name !== config.siteName &&
!(sessionResult.value.site.name === 'Default' && !config.siteName)
) {
const sentences = [
`User signed in to site: ${sessionResult.value.site.name || 'Default'}.`,
`Expected site: ${config.siteName || 'Default'}.`,
`Please reconnect your client and choose the [${config.siteName || 'Default'}] site in the site picker if prompted.`,
];

res.status(400).json({
error: 'invalid_request',
error_description: sentences.join(' '),
});
return;
}

// Generate authorization code
const authorizationCode = randomBytes(32).toString('hex');
authorizationCodes.set(authorizationCode, {
Expand Down
106 changes: 105 additions & 1 deletion tests/oauth/authorizationCodeCallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('authorization code callback', () => {
});

afterEach(async () => {
vi.unstubAllEnvs();
await new Promise<void>((resolve) => {
if (_server) {
_server.close(() => {
Expand All @@ -49,6 +50,21 @@ describe('authorization code callback', () => {
return { app };
}

it('should reject if the request is invalid', async () => {
const { app } = await startServer();

const response = await request(app).get('/Callback').query({
error: 'invalid_request',
});

expect(response.status).toBe(400);
expect(response.headers['content-type']).toBe('application/json; charset=utf-8');
expect(response.body).toEqual({
error: 'invalid_request',
error_description: 'Invalid request. Did you sign into the wrong site? Expected: mcp-test',
});
});

it('should reject if user denies authorization', async () => {
const { app } = await startServer();

Expand Down Expand Up @@ -152,7 +168,95 @@ describe('authorization code callback', () => {
});
});

it('should issue an authorization code when the Tableau access token is successfully retrieved', async () => {
it('should reject if the user signs in to a different site other than the locked, expected site', async () => {
vi.stubEnv('SITE_NAME', 'other-site');

const { app } = await startServer();

const authzResponse = await request(app).get('/oauth/authorize').query({
client_id: 'test-client-id',
redirect_uri: 'http://localhost:3000',
response_type: 'code',
code_challenge: 'test-code-challenge',
code_challenge_method: 'S256',
state: 'test-state',
});

const authzLocation = new URL(authzResponse.headers['location']);
const [authKey, tableauState] = authzLocation.searchParams.get('state')?.split(':') ?? [];

mocks.mockGetTokenResult.mockResolvedValue({
accessToken: 'test-access-token',
refreshToken: 'test-refresh-token',
expiresInSeconds: 3600,
originHost: '10ax.online.tableau.com',
});

const response = await request(app)
.get('/Callback')
.query({
code: 'test-code',
state: `${authKey}:${tableauState}`,
});

expect(response.status).toBe(400);
expect(response.headers['content-type']).toBe('application/json; charset=utf-8');
expect(response.body).toEqual({
error: 'invalid_request',
error_description:
'User signed in to site: mcp-test. Expected site: other-site. Please reconnect your client and choose the [other-site] site in the site picker if prompted.',
});
});

it('should issue an authorization code when the Tableau access token is successfully retrieved when site locking is disabled', async () => {
vi.stubEnv('SITE_NAME', 'other-site');
vi.stubEnv('OAUTH_LOCK_SITE', 'false');

const { app } = await startServer();

const authzResponse = await request(app).get('/oauth/authorize').query({
client_id: 'test-client-id',
redirect_uri: 'http://localhost:3000',
response_type: 'code',
code_challenge: 'test-code-challenge',
code_challenge_method: 'S256',
state: 'test-state',
});

const authzLocation = new URL(authzResponse.headers['location']);
const searchParams = new URLSearchParams(
authzLocation.hash.substring(authzLocation.hash.indexOf('?')),
);

const externalRedirectUrl = new URL(
searchParams.get('externalRedirect') ?? '',
authzLocation.origin,
);

const [authKey, tableauState] = externalRedirectUrl.searchParams.get('state')?.split(':') ?? [];

mocks.mockGetTokenResult.mockResolvedValue({
accessToken: 'test-access-token',
refreshToken: 'test-refresh-token',
expiresInSeconds: 3600,
originHost: '10ax.online.tableau.com',
});

const response = await request(app)
.get('/Callback')
.query({
code: 'test-code',
state: `${authKey}:${tableauState}`,
});

expect(response.status).toBe(302);
const location = new URL(response.headers['location']);
expect(location.origin).toBe('http://localhost:3000');
expect(location.searchParams.get('code')).not.toBeNull();
expect(location.searchParams.get('state')).toBe('test-state');
});

it('should issue an authorization code when the Tableau access token is successfully retrieved when site locking is enabled', async () => {
const { app } = await startServer();

const authzResponse = await request(app).get('/oauth/authorize').query({
Expand Down
2 changes: 1 addition & 1 deletion tests/oauth/testSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ vi.mock('../../src/sdks/tableau/restApi.js', async (importOriginal) => ({
Ok({
site: {
id: 'site_id',
name: 'test-site',
name: 'mcp-test',
},
user: {
id: 'user_id',
Expand Down
1 change: 1 addition & 0 deletions types/process-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface ProcessEnvEx {
OAUTH_JWE_PRIVATE_KEY_PASSPHRASE: string | undefined;
OAUTH_CIMD_DNS_SERVERS: string | undefined;
OAUTH_REDIRECT_URI: string | undefined;
OAUTH_LOCK_SITE: string | undefined;
OAUTH_CLIENT_ID_SECRET_PAIRS: string | undefined;
OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: string | undefined;
OAUTH_ACCESS_TOKEN_TIMEOUT_MS: string | undefined;
Expand Down