diff --git a/docs/docs/configuration/mcp-config/oauth.md b/docs/docs/configuration/mcp-config/oauth.md index 4c9164cc..4c4c7be1 100644 --- a/docs/docs/configuration/mcp-config/oauth.md +++ b/docs/docs/configuration/mcp-config/oauth.md @@ -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. +
+ +### `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.
### `OAUTH_REDIRECT_URI` diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index ed57ee55..9f25610e 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -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; +} diff --git a/package-lock.json b/package-lock.json index 69636455..2f161798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "1.13.10", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "1.13.10", + "version": "1.14.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index b42d45bf..96e5ecff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tableau/mcp-server", "description": "An MCP server for Tableau, providing a suite of tools that will make it easier for developers to build AI applications that integrate with Tableau.", - "version": "1.13.10", + "version": "1.14.0", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" diff --git a/src/config.test.ts b/src/config.test.ts index 91286bb4..9d407feb 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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, @@ -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, @@ -1103,6 +1105,7 @@ describe('Config', () => { issuer: '', clientIdSecretPairs: null, redirectUri: '', + lockSite: true, jwePrivateKey: '', jwePrivateKeyPath: '', jwePrivateKeyPassphrase: undefined, @@ -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, diff --git a/src/config.ts b/src/config.ts index 677b27a9..8e0093ac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,7 @@ export class Config { enabled: boolean; issuer: string; redirectUri: string; + lockSite: boolean; jwePrivateKey: string; jwePrivateKeyPath: string; jwePrivateKeyPassphrase: string | undefined; @@ -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, @@ -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, diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts index 70d8cb62..69c363fc 100644 --- a/src/scripts/createClaudeMcpBundleManifest.ts +++ b/src/scripts/createClaudeMcpBundleManifest.ts @@ -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', diff --git a/src/server/oauth/authorize.ts b/src/server/oauth/authorize.ts index 99d64b5f..d6abfef8 100644 --- a/src/server/oauth/authorize.ts +++ b/src/server/oauth/authorize.ts @@ -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 { + 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, diff --git a/src/server/oauth/callback.ts b/src/server/oauth/callback.ts index 13c42752..b37d3e4b 100644 --- a/src/server/oauth/callback.ts +++ b/src/server/oauth/callback.ts @@ -39,10 +39,19 @@ 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 in to the wrong site? From your browser, please sign out of your site and reconnect your agent to Tableau MCP.', + }); + } else { + res.status(400).json({ + error: 'access_denied', + error_description: 'User denied authorization', + }); + } + return; } @@ -114,6 +123,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, { diff --git a/tests/oauth/authorizationCodeCallback.test.ts b/tests/oauth/authorizationCodeCallback.test.ts index 469605dd..aa33f996 100644 --- a/tests/oauth/authorizationCodeCallback.test.ts +++ b/tests/oauth/authorizationCodeCallback.test.ts @@ -27,6 +27,7 @@ describe('authorization code callback', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); await new Promise((resolve) => { if (_server) { _server.close(() => { @@ -49,6 +50,22 @@ 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 in to the wrong site? From your browser, please sign out of your site and reconnect your agent to Tableau MCP.', + }); + }); + it('should reject if user denies authorization', async () => { const { app } = await startServer(); @@ -152,7 +169,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({ diff --git a/tests/oauth/testSetup.ts b/tests/oauth/testSetup.ts index 80a6f937..28d0e29c 100644 --- a/tests/oauth/testSetup.ts +++ b/tests/oauth/testSetup.ts @@ -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', diff --git a/types/process-env.d.ts b/types/process-env.d.ts index eefc11c6..4852b5c5 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -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;