diff --git a/OAUTH_IMPLEMENTATION_PLAN.md b/OAUTH_IMPLEMENTATION_PLAN.md
new file mode 100644
index 00000000..d6fbe1f0
--- /dev/null
+++ b/OAUTH_IMPLEMENTATION_PLAN.md
@@ -0,0 +1,301 @@
+# Tableau MCP OAuth Integration Implementation Plan
+
+## Executive Summary
+
+This document outlines the phased approach to integrate Tableau OAuth with the MCP server, implementing MCP authorization best practices, scope management, and token exchange mechanisms. The work is divided into three phases, with Phase 1 focusing on immediate improvements we can make independently while coordinating with the auth team on Phase 2 and 3 requirements.
+
+---
+
+## Current State Analysis
+
+### Existing OAuth Implementation
+- ✅ Basic OAuth 2.1 flow with PKCE implemented
+- ✅ Authorization server metadata endpoint (`.well-known/oauth-authorization-server`)
+- ✅ Protected resource metadata endpoint (`.well-known/oauth-protected-resource`)
+- ✅ Client ID Metadata Documents support
+- ✅ Dynamic client registration support
+- ✅ Token endpoint with authorization_code, refresh_token, and client_credentials grants
+- ✅ JWE-encrypted access tokens
+
+### Gaps Identified
+- ❌ **No scope support**: `scopes_supported: []` in authorization server metadata
+- ❌ **No scope parameter** in `WWW-Authenticate` header (per MCP spec recommendation)
+- ❌ **No scope validation** in authorization or token endpoints
+- ❌ **No scope-to-Tableau mapping** mechanism
+- ❌ **Token exchange** needs to be adapted for Tableau REST API compatibility
+- ❌ **Scope challenge handling** not implemented (for insufficient scope errors)
+- ❌ **Step-up authorization flow** not implemented (for requesting additional scopes)
+- ⚠️ **Testing gaps**: Long-lived token testing (30-day refresh tokens), scope edge cases
+
+---
+
+## Phase 1: Best Practices & Auth Uplift (Independent Work)
+
+**Timeline**: 2-3 weeks
+**Dependencies**: None (can start immediately)
+
+### 1.1 Review MCP Authorization Specification Changes
+- [ ] Review latest MCP spec (2025-11-25) vs current implementation (2025-06-18)
+- [ ] Document any breaking changes or new requirements
+- [ ] Update code references to latest spec version
+- [ ] Review Security Best Practices guide: https://modelcontextprotocol.io/docs/tutorials/security/authorization
+
+### 1.2 Implement Scope Infrastructure
+**Files to modify:**
+- `src/server/oauth/.well-known/oauth-authorization-server.ts`
+- `src/server/oauth/.well-known/oauth-protected-resource.ts`
+- `src/server/oauth/authMiddleware.ts`
+- `src/server/oauth/authorize.ts`
+- `src/server/oauth/schemas.ts`
+- `src/server/oauth/token.ts`
+
+**Tasks:**
+- [ ] Add scope configuration to config (environment variable or config file)
+- [ ] Define scope data structures and types
+- [ ] Implement `scopes_supported` in authorization server metadata
+- [ ] Add scope parameter parsing in authorize endpoint
+- [ ] Add scope validation logic
+- [ ] Store requested scopes in pending authorization and authorization codes
+- [ ] Include scopes in issued access tokens (JWE payload)
+- [ ] Add scope extraction in auth middleware
+
+### 1.3 Implement WWW-Authenticate Scope Guidance
+**Per MCP Spec Section: Protected Resource Metadata Discovery Requirements**
+
+- [ ] Add `scope` parameter to `WWW-Authenticate` header in `authMiddleware.ts`
+- [ ] Determine appropriate scopes for each MCP endpoint/tool
+- [ ] Implement scope selection strategy (per MCP spec)
+- [ ] Handle scope challenges in 401 responses
+
+### 1.4 Scope Research & Documentation
+- [ ] Research Tableau OAuth scope format and structure
+- [ ] Document scope naming conventions
+- [ ] Create scope inventory: what scopes does Tableau OAuth support?
+- [ ] Document scope lifecycle: adding/removing scopes without breaking existing clients
+- [ ] Design scope versioning strategy (if needed)
+
+### 1.5 Code Quality Improvements
+- [ ] Review and improve error handling in OAuth flows
+- [ ] Add comprehensive logging for OAuth operations
+- [ ] Improve token validation error messages
+- [ ] Add input validation for scope parameters
+- [ ] Review security best practices implementation:
+ - [ ] Token audience validation (already implemented via `AUDIENCE`)
+ - [ ] PKCE enforcement (already implemented)
+ - [ ] Redirect URI validation (already implemented)
+ - [ ] SSRF protection (already implemented in client metadata fetching)
+
+### 1.6 Testing Infrastructure Updates
+- [ ] Update OAuth test suite to handle scopes
+- [ ] Add tests for scope validation
+- [ ] Add tests for WWW-Authenticate scope parameter
+- [ ] Document testing approach for long-lived tokens (30-day refresh tokens)
+- [ ] Create test utilities for simulating token expiration scenarios
+
+---
+
+## Phase 2: Scope Mapping (Coordination with Auth Team)
+
+**Timeline**: 3-4 weeks
+**Dependencies**: Auth team decisions on scope mapping
+
+### 2.1 Scope Mapping Design & Implementation
+**Coordination needed with George:**
+- [ ] Finalize scope mapping strategy (MCP scopes → Tableau scopes)
+- [ ] Define scope mapping configuration format
+- [ ] Implement scope mapping logic
+- [ ] Handle scope translation in authorization flow
+- [ ] Handle scope translation in token exchange
+
+**Implementation tasks:**
+- [ ] Create scope mapping module/utility
+- [ ] Add scope mapping configuration to config
+- [ ] Integrate scope mapping into authorize endpoint (when forwarding to Tableau OAuth)
+- [ ] Integrate scope mapping into token exchange
+
+### 2.2 Token Exchange
+Token exchange is **out of scope** for the initial release. Revisit only if the authorization
+strategy changes or Tableau OAuth requirements evolve.
+
+### 2.3 Authorization Flow Updates
+- [ ] Update authorize endpoint to include mapped scopes in Tableau OAuth redirect
+- [ ] Handle scope parameter from client requests
+- [ ] Validate requested scopes against supported scopes
+- [ ] Store scope information throughout the flow
+
+### 2.4 Testing
+- [ ] Integration tests with Tableau OAuth
+- [ ] Test scope mapping in various scenarios
+- [ ] Test with actual Tableau REST API calls
+
+---
+
+## Phase 3: Server Support & Full Cloud Integration
+
+**Timeline**: 2-3 weeks
+**Dependencies**: Phase 2 completion, Server vs Cloud requirements clarification
+
+### 3.1 Server vs Cloud Differentiation
+**Decisions needed:**
+- [ ] Determine if Server needs scope support (or can skip for beta)
+- [ ] Define configuration knobs/environment variables for Server vs Cloud behavior
+- [ ] Determine if Server OAuth flow differs from Cloud
+
+**Implementation tasks:**
+- [ ] Add environment variable/config option for Server mode
+- [ ] Implement conditional scope handling (skip scopes in Server mode if needed)
+- [ ] Ensure Server OAuth flow still works without breaking changes
+- [ ] Add Server-specific configuration options
+
+### 3.2 Full Cloud Integration
+- [ ] Complete integration with Tableau Cloud OAuth
+- [ ] Test end-to-end flow with Tableau Cloud
+- [ ] Verify token exchange works with Cloud
+- [ ] Verify scope mapping works with Cloud
+- [ ] Performance testing with Cloud
+
+### 3.3 Edge Cases & Error Handling
+**Coordination with Andy:**
+- [ ] Review edge cases identified
+- [ ] Implement handling for:
+ - [ ] Insufficient scope errors (scope challenge)
+ - [ ] Step-up authorization (requesting additional scopes)
+ - [ ] Token expiration during long-running operations
+ - [ ] Network failures during token exchange
+ - [ ] Invalid scope requests
+ - [ ] Scope revocation scenarios
+
+### 3.4 Testing & Validation
+- [ ] End-to-end testing with both Server and Cloud
+- [ ] Test scope addition/removal scenarios
+- [ ] Test backward compatibility (clients without scope support)
+- [ ] Load testing with token refresh
+- [ ] Security testing (token validation, scope validation)
+
+---
+
+## Technical Implementation Details
+
+### Scope Data Structure
+```typescript
+// Proposed scope structure
+type McpScope =
+ | 'tableau:content:read'
+ | 'tableau:content:write'
+ | 'tableau:datasource:query'
+ | 'tableau:workbook:read'
+ | 'tableau:view:read'
+ | 'tableau:view:download'
+ // ... additional scopes as needed
+
+interface ScopeMapping {
+ mcpScope: McpScope;
+ tableauScopes: string[]; // Array of Tableau OAuth scopes
+}
+```
+
+### WWW-Authenticate Header Enhancement
+```typescript
+// Current (line 51 in authMiddleware.ts):
+`Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`
+
+// Enhanced (per MCP spec):
+`Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="tableau:content:read tableau:datasource:query"`
+```
+
+### Configuration Additions
+```typescript
+// Add to config.ts
+oauth: {
+ // ... existing config
+ scopesSupported: string[]; // MCP scopes we support
+ scopeMappings: ScopeMapping[]; // MCP → Tableau scope mappings
+ serverMode?: boolean; // If true, may skip scope requirements
+}
+```
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+- Scope validation logic
+- Scope mapping logic
+- Token exchange logic
+- WWW-Authenticate header generation
+
+### Integration Tests
+- Full OAuth flow with scopes
+- Token exchange with Tableau OAuth
+- Scope challenge handling
+- Step-up authorization
+
+### E2E Tests
+- Complete flow with MCP client
+- Token refresh scenarios
+- Long-lived token testing (30-day refresh tokens)
+- Server vs Cloud behavior
+
+### Test Utilities Needed
+- Mock Tableau OAuth server with scope support
+- Token expiration simulation
+- Scope challenge simulation
+- Long-running operation simulation
+
+---
+
+## Open Questions for Auth Team
+
+1. **Scope Format**: What is the exact format of Tableau OAuth scopes? (e.g., `tableau:content:read`, `tableau.content.read`, etc.)
+
+2. **Scope Mapping**: How should we map MCP scopes to Tableau scopes? One-to-one, one-to-many, or many-to-one?
+
+3. **Token Exchange**:
+ - Not planned for initial release. Revisit if requirements change.
+
+4. **Scope Lifecycle**:
+ - How do we add new scopes without breaking existing clients?
+ - How do we handle scope deprecation?
+ - Can scopes be requested dynamically, or must they be pre-registered?
+
+5. **Server vs Cloud**:
+ - Does Server OAuth support the same scope mechanism as Cloud?
+ - Should Server skip scope requirements for beta?
+
+6. **Token Lifetime**:
+ - What are the actual token lifetimes (access, refresh)?
+ - How should we test 30-day refresh tokens?
+
+---
+
+## Success Criteria
+
+### Phase 1
+- ✅ MCP authorization best practices implemented
+- ✅ Scope infrastructure in place
+- ✅ WWW-Authenticate scope guidance implemented
+- ✅ Comprehensive scope research documented
+- ✅ Code quality improvements completed
+
+### Phase 2
+- ✅ Scope mapping implemented and tested
+- ✅ Token exchange working with Tableau OAuth
+- ✅ Full authorization flow with scopes working end-to-end
+
+### Phase 3
+- ✅ Server and Cloud both supported
+- ✅ All edge cases handled
+- ✅ Comprehensive test coverage
+- ✅ Production-ready implementation
+
+---
+
+## References
+
+- [MCP Authorization Specification (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
+- [MCP Security Best Practices](https://modelcontextprotocol.io/docs/tutorials/security/authorization)
+- [OAuth 2.1 IETF Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13)
+- [OAuth 2.0 Protected Resource Metadata (RFC9728)](https://www.rfc-editor.org/rfc/rfc9728)
+- [OAuth 2.0 Authorization Server Metadata (RFC8414)](https://www.rfc-editor.org/rfc/rfc8414)
+
+
diff --git a/docs/docs/configuration/mcp-config/oauth.md b/docs/docs/configuration/mcp-config/oauth.md
index 7abe33f5..9b07a787 100644
--- a/docs/docs/configuration/mcp-config/oauth.md
+++ b/docs/docs/configuration/mcp-config/oauth.md
@@ -14,15 +14,19 @@ is accessed using a local development URL e.g. `http://127.0.0.1:3927/tableau-mc
## How to Enable OAuth
-To enable OAuth, set the [`OAUTH_ISSUER`](#oauth_issuer) environment variable to the origin of your MCP server. When a URL for `OAUTH_ISSUER` is provided, the MCP server will act as an OAuth 2.1 resource server, capable of accepting and responding to protected resource requests using encrypted access tokens.
+To enable OAuth, set the [`OAUTH_ISSUER`](#oauth_issuer) environment variable to the origin of your
+MCP server. When a URL for `OAUTH_ISSUER` is provided, the MCP server will act as an OAuth 2.1
+resource server, capable of accepting and responding to protected resource requests using encrypted
+access tokens.
When OAuth is enabled:
-- MCP clients will be required to authenticate via Tableau OAuth before connecting to the MCP server
+- MCP clients will be required to authenticate via Tableau OAuth before connecting to the MCP
+ server
- The [`TRANSPORT`](#transport) will default to `http` (required for OAuth)
- The [`AUTH`](#auth) method will default to `oauth`
For more information, please see the
-[MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization).
+[MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
@@ -168,6 +172,37 @@ Where `Y2xpZW50SWQ6c2VjcmV0` is the base64 encoding of `clientId:secret`.
+### `OAUTH_SCOPES_SUPPORTED`
+
+A space- or comma-separated list of scopes supported by the MCP server.
+
+- Optional, but recommended when OAuth is enabled.
+- Used to populate `scopes_supported` in the OAuth metadata.
+- Example: `tableau:content:read tableau:view:read tableau:datasource:query`
+
+
+
+### `OAUTH_REQUIRED_SCOPES`
+
+A space- or comma-separated list of scopes required to access the MCP server.
+
+- Optional. If not set, all values from `OAUTH_SCOPES_SUPPORTED` are required.
+- The MCP server will include these in `WWW-Authenticate` challenges and enforce them on requests.
+- Example: `tableau:content:read tableau:view:read`
+
+
+
+### `OAUTH_DISABLE_SCOPES`
+
+Disable scope enforcement and scope challenges.
+
+- Default: `false`
+- Useful for Tableau Server deployments that do not want to enforce scopes yet.
+- When `true`, the MCP server will not include scopes in `WWW-Authenticate` challenges and will not
+ enforce scopes on incoming access tokens.
+
+
+
### `OAUTH_JWE_PRIVATE_KEY`
The RSA private key used to decrypt the OAuth access token.
diff --git a/env.example.list b/env.example.list
index 497feff9..00a072b7 100644
--- a/env.example.list
+++ b/env.example.list
@@ -9,4 +9,7 @@ INCLUDE_TOOLS=
EXCLUDE_TOOLS=
MAX_RESULT_LIMIT=
DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS=
-DISABLE_METADATA_API_REQUESTS=
\ No newline at end of file
+DISABLE_METADATA_API_REQUESTS=
+OAUTH_SCOPES_SUPPORTED=
+OAUTH_REQUIRED_SCOPES=
+OAUTH_DISABLE_SCOPES=
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 0378ea39..d8a27c65 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2521,9 +2521,9 @@
}
},
"node_modules/axios": {
- "version": "1.12.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz",
- "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==",
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
+ "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"license": "MIT",
"peer": true,
"dependencies": {
diff --git a/src/config.test.ts b/src/config.test.ts
index 3a16e59a..cd3ddfe3 100644
--- a/src/config.test.ts
+++ b/src/config.test.ts
@@ -663,6 +663,9 @@ describe('Config', () => {
jwePrivateKeyPath: 'path/to/private.pem',
jwePrivateKeyPassphrase: undefined,
dnsServers: ['1.1.1.1', '1.0.0.1'],
+ scopesSupported: [],
+ requiredScopes: [],
+ enforceScopes: false,
...defaultOAuthTimeoutMs,
} as const;
@@ -678,6 +681,9 @@ describe('Config', () => {
jwePrivateKeyPath: '',
jwePrivateKeyPassphrase: undefined,
dnsServers: ['1.1.1.1', '1.0.0.1'],
+ scopesSupported: [],
+ requiredScopes: [],
+ enforceScopes: false,
...defaultOAuthTimeoutMs,
});
});
diff --git a/src/config.ts b/src/config.ts
index 7e15205e..1e737d3d 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -81,6 +81,9 @@ export class Config {
refreshTokenTimeoutMs: number;
clientIdSecretPairs: Record | null;
dnsServers: string[];
+ scopesSupported: string[];
+ requiredScopes: string[];
+ enforceScopes: boolean;
};
telemetry: TelemetryConfig;
productTelemetryEndpoint: string;
@@ -146,6 +149,9 @@ export class Config {
OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: authzCodeTimeoutMs,
OAUTH_ACCESS_TOKEN_TIMEOUT_MS: accessTokenTimeoutMs,
OAUTH_REFRESH_TOKEN_TIMEOUT_MS: refreshTokenTimeoutMs,
+ OAUTH_SCOPES_SUPPORTED: oauthScopesSupported,
+ OAUTH_REQUIRED_SCOPES: oauthRequiredScopes,
+ OAUTH_DISABLE_SCOPES: oauthDisableScopes,
TELEMETRY_PROVIDER: telemetryProvider,
TELEMETRY_PROVIDER_CONFIG: telemetryProviderConfig,
PRODUCT_TELEMETRY_ENDPOINT: productTelemetryEndpoint,
@@ -215,6 +221,16 @@ export class Config {
);
const disableOauthOverride = disableOauth === 'true';
+ const scopesSupported = parseScopes(oauthScopesSupported);
+ const requiredScopes = parseScopes(oauthRequiredScopes);
+ const disableScopes = oauthDisableScopes === 'true';
+ const effectiveRequiredScopes = disableScopes
+ ? []
+ : requiredScopes.length > 0
+ ? requiredScopes
+ : scopesSupported;
+ const enforceScopes = !disableScopes && effectiveRequiredScopes.length > 0;
+
this.oauth = {
enabled: disableOauthOverride ? false : !!oauthIssuer,
issuer: oauthIssuer ?? '',
@@ -250,6 +266,9 @@ export class Config {
return acc;
}, {})
: null,
+ scopesSupported,
+ requiredScopes: effectiveRequiredScopes,
+ enforceScopes,
};
const parsedProvider = isTelemetryProvider(telemetryProvider) ? telemetryProvider : 'noop';
@@ -465,6 +484,17 @@ function getCorsOriginConfig(corsOriginConfig: string): CorsOptions['origin'] {
}
}
+function parseScopes(value: string | undefined): string[] {
+ if (!value) {
+ return [];
+ }
+
+ return value
+ .split(/[,\s]+/)
+ .map((scope) => scope.trim())
+ .filter((scope) => scope.length > 0);
+}
+
function getTrustProxyConfig(trustProxyConfig: string): boolean | number | string | null {
if (!trustProxyConfig) {
return null;
diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts
index 50ba844d..6074d2e7 100644
--- a/src/restApiInstance.ts
+++ b/src/restApiInstance.ts
@@ -16,25 +16,16 @@ import {
import { RestApi } from './sdks/tableau/restApi.js';
import { Server, userAgent } from './server.js';
import { TableauAuthInfo } from './server/oauth/schemas.js';
+import { TableauApiScope } from './server/oauth/scopes.js';
import { isAxiosError } from './utils/axios.js';
import { getExceptionMessage } from './utils/getExceptionMessage.js';
import invariant from './utils/invariant.js';
-type JwtScopes =
- | 'tableau:viz_data_service:read'
- | 'tableau:content:read'
- | 'tableau:insight_definitions_metrics:read'
- | 'tableau:insight_metrics:read'
- | 'tableau:metric_subscriptions:read'
- | 'tableau:insights:read'
- | 'tableau:views:download'
- | 'tableau:insight_brief:create';
-
const getNewRestApiInstanceAsync = async (
config: Config,
requestId: RequestId,
server: Server,
- jwtScopes: Set,
+ jwtScopes: Set,
signal: AbortSignal,
authInfo?: TableauAuthInfo,
): Promise => {
@@ -124,7 +115,7 @@ export const useRestApi = async ({
config: Config;
requestId: RequestId;
server: Server;
- jwtScopes: Array;
+ jwtScopes: Array;
signal: AbortSignal;
callback: (restApi: RestApi) => Promise;
authInfo?: TableauAuthInfo;
diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts
index 5d13e12c..69b84d7f 100644
--- a/src/scripts/createClaudeMcpBundleManifest.ts
+++ b/src/scripts/createClaudeMcpBundleManifest.ts
@@ -190,6 +190,22 @@ const envVars = {
required: false,
sensitive: false,
},
+ TELEMETRY_PROVIDER: {
+ includeInUserConfig: false,
+ type: 'string',
+ title: 'Telemetry Provider',
+ description: 'The telemetry provider to use for server telemetry.',
+ required: false,
+ sensitive: false,
+ },
+ TELEMETRY_PROVIDER_CONFIG: {
+ includeInUserConfig: false,
+ type: 'string',
+ title: 'Telemetry Provider Config',
+ description: 'Provider-specific configuration for telemetry.',
+ required: false,
+ sensitive: false,
+ },
DEFAULT_LOG_LEVEL: {
includeInUserConfig: false,
type: 'string',
@@ -439,6 +455,30 @@ const envVars = {
required: false,
sensitive: false,
},
+ OAUTH_SCOPES_SUPPORTED: {
+ includeInUserConfig: false,
+ type: 'string',
+ title: 'OAuth Scopes Supported',
+ description: 'A space- or comma-separated list of scopes supported by the MCP server.',
+ required: false,
+ sensitive: false,
+ },
+ OAUTH_REQUIRED_SCOPES: {
+ includeInUserConfig: false,
+ type: 'string',
+ title: 'OAuth Required Scopes',
+ description: 'A space- or comma-separated list of scopes required to access the MCP server.',
+ required: false,
+ sensitive: false,
+ },
+ OAUTH_DISABLE_SCOPES: {
+ includeInUserConfig: false,
+ type: 'boolean',
+ title: 'OAuth Disable Scopes',
+ description: 'Disable scope enforcement and scope challenges when set to true.',
+ required: false,
+ sensitive: false,
+ },
OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: {
includeInUserConfig: false,
type: 'number',
diff --git a/src/server/middleware.ts b/src/server/middleware.ts
index e8650133..b9a871c3 100644
--- a/src/server/middleware.ts
+++ b/src/server/middleware.ts
@@ -14,7 +14,7 @@ export function validateProtocolVersion(req: Request, res: Response, next: NextF
}
// Check supported versions
- const supportedVersions = ['2025-06-18', '2025-03-26', '2024-11-05'];
+ const supportedVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
if (!supportedVersions.includes(version as string)) {
res.status(400).json({
jsonrpc: '2.0',
diff --git a/src/server/oauth/.well-known/oauth-authorization-server.ts b/src/server/oauth/.well-known/oauth-authorization-server.ts
index 97b9e7b9..a6d10cda 100644
--- a/src/server/oauth/.well-known/oauth-authorization-server.ts
+++ b/src/server/oauth/.well-known/oauth-authorization-server.ts
@@ -10,17 +10,17 @@ import { getConfig } from '../../../config.js';
*/
export function oauthAuthorizationServer(app: express.Application): void {
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
- const origin = getConfig().oauth.issuer;
+ const { issuer, scopesSupported } = getConfig().oauth;
res.json({
- issuer: origin,
- authorization_endpoint: `${origin}/oauth/authorize`,
- token_endpoint: `${origin}/oauth/token`,
- registration_endpoint: `${origin}/oauth/register`,
+ issuer,
+ authorization_endpoint: `${issuer}/oauth/authorize`,
+ token_endpoint: `${issuer}/oauth/token`,
+ registration_endpoint: `${issuer}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
- scopes_supported: [],
- token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
+ scopes_supported: scopesSupported,
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
subject_types_supported: ['public'],
client_id_metadata_document_supported: true,
});
diff --git a/src/server/oauth/authMiddleware.ts b/src/server/oauth/authMiddleware.ts
index c8fd7200..73e76ebd 100644
--- a/src/server/oauth/authMiddleware.ts
+++ b/src/server/oauth/authMiddleware.ts
@@ -8,6 +8,7 @@ import { fromError } from 'zod-validation-error';
import { getConfig } from '../../config.js';
import { AUDIENCE } from './provider.js';
import { mcpAccessTokenSchema, mcpAccessTokenUserOnlySchema, TableauAuthInfo } from './schemas.js';
+import { formatScopes, parseScopes } from './scopes.js';
import { AuthenticatedRequest } from './types.js';
/**
@@ -44,11 +45,16 @@ export function authMiddleware(privateKey: KeyObject): RequestHandler {
}
const baseUrl = `${req.protocol}://${req.get('host')}`;
+ const { enforceScopes, requiredScopes } = getConfig().oauth;
+ const scopeParam =
+ enforceScopes && requiredScopes.length > 0
+ ? `, scope="${formatScopes(requiredScopes)}"`
+ : '';
res
.status(401)
.header(
'WWW-Authenticate',
- `Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`,
+ `Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"${scopeParam}`,
)
.json({
error: 'unauthorized',
@@ -80,7 +86,41 @@ export function authMiddleware(privateKey: KeyObject): RequestHandler {
});
return;
}
- req.auth = result.value;
+ const authInfo = result.value;
+ const { enforceScopes, requiredScopes } = getConfig().oauth;
+ if (enforceScopes && requiredScopes.length > 0) {
+ const missingRequiredScopes = requiredScopes.filter(
+ (scope) => !authInfo.scopes.includes(scope),
+ );
+ if (missingRequiredScopes.length > 0) {
+ const baseUrl = `${req.protocol}://${req.get('host')}`;
+ const scopeParam = `scope="${formatScopes(requiredScopes)}"`;
+ const wwwAuthenticate = `Bearer realm="MCP", error="insufficient_scope", error_description="Missing required scopes", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", ${scopeParam}`;
+
+ if (req.method === 'GET' && req.headers.accept?.includes('text/event-stream')) {
+ res.writeHead(403, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ 'WWW-Authenticate': wwwAuthenticate,
+ });
+ res.write('event: error\n');
+ res.write(
+ 'data: {"error": "insufficient_scope", "error_description": "Missing required scopes"}\n\n',
+ );
+ res.end();
+ return;
+ }
+
+ res.status(403).header('WWW-Authenticate', wwwAuthenticate).json({
+ error: 'insufficient_scope',
+ error_description: 'Missing required scopes',
+ });
+ return;
+ }
+ }
+
+ req.auth = authInfo;
next();
};
}
@@ -118,6 +158,7 @@ async function verifyAccessToken(
return new Err('Invalid or expired access token');
}
+ const tokenScopes = parseScopes(mcpAccessToken.data.scope);
let tableauAuthInfo: TableauAuthInfo;
if (config.auth === 'oauth') {
const mcpAccessToken = mcpAccessTokenSchema.safeParse(payload);
@@ -157,7 +198,7 @@ async function verifyAccessToken(
return Ok({
token,
clientId,
- scopes: [],
+ scopes: tokenScopes,
expiresAt: payload.exp,
extra: tableauAuthInfo,
});
diff --git a/src/server/oauth/authorize.ts b/src/server/oauth/authorize.ts
index f133b2b0..03078230 100644
--- a/src/server/oauth/authorize.ts
+++ b/src/server/oauth/authorize.ts
@@ -16,6 +16,7 @@ import { generateCodeChallenge } from './generateCodeChallenge.js';
import { isValidRedirectUri } from './isValidRedirectUri.js';
import { TABLEAU_CLOUD_SERVER_URL } from './provider.js';
import { cimdMetadataSchema, ClientMetadata, mcpAuthorizeSchema } from './schemas.js';
+import { parseScopes, validateScopes } from './scopes.js';
import { PendingAuthorization } from './types.js';
/**
@@ -42,8 +43,15 @@ export function authorize(
return;
}
- const { client_id, redirect_uri, response_type, code_challenge, code_challenge_method, state } =
- result.data;
+ const {
+ client_id,
+ redirect_uri,
+ response_type,
+ code_challenge,
+ code_challenge_method,
+ state,
+ scope,
+ } = result.data;
const clientIdUrl = parseUrl(client_id);
if (clientIdUrl) {
@@ -97,6 +105,33 @@ export function authorize(
return;
}
+ const { enforceScopes, requiredScopes, scopesSupported } = config.oauth;
+ const requestedScopes = parseScopes(scope);
+ const { valid: validScopes, invalid: invalidScopes } = validateScopes(
+ requestedScopes,
+ scopesSupported,
+ );
+
+ if (invalidScopes.length > 0) {
+ res.status(400).json({
+ error: 'invalid_scope',
+ error_description: `Unsupported scopes: ${invalidScopes.join(', ')}`,
+ });
+ return;
+ }
+
+ const scopesToGrant = enforceScopes && validScopes.length === 0 ? requiredScopes : validScopes;
+ if (enforceScopes) {
+ const missingRequiredScopes = requiredScopes.filter((s) => !scopesToGrant.includes(s));
+ if (missingRequiredScopes.length > 0) {
+ res.status(400).json({
+ error: 'invalid_scope',
+ error_description: `Missing required scopes: ${missingRequiredScopes.join(', ')}`,
+ });
+ return;
+ }
+ }
+
// Generate Tableau state and store pending authorization
const tableauState = randomBytes(32).toString('hex');
const authKey = randomBytes(32).toString('hex');
@@ -114,6 +149,7 @@ export function authorize(
tableauState,
tableauClientId,
tableauCodeVerifier,
+ scopes: scopesToGrant,
});
// Clean up expired authorizations
diff --git a/src/server/oauth/callback.ts b/src/server/oauth/callback.ts
index b37d3e4b..27365fe8 100644
--- a/src/server/oauth/callback.ts
+++ b/src/server/oauth/callback.ts
@@ -150,6 +150,7 @@ export function callback(
user: sessionResult.value.user,
server,
tableauClientId: pendingAuth.tableauClientId,
+ scopes: pendingAuth.scopes,
tokens: {
accessToken,
refreshToken,
diff --git a/src/server/oauth/provider.ts b/src/server/oauth/provider.ts
index bb9c56cf..5c604d9d 100644
--- a/src/server/oauth/provider.ts
+++ b/src/server/oauth/provider.ts
@@ -19,7 +19,7 @@ export const AUDIENCE = 'tableau-mcp-server';
* OAuth 2.1 Provider
*
* Implements the complete MCP OAuth 2.1 flow with PKCE
- * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
+ * @see https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
*
*/
export class OAuthProvider {
diff --git a/src/server/oauth/schemas.ts b/src/server/oauth/schemas.ts
index ff56cc42..b7d2141f 100644
--- a/src/server/oauth/schemas.ts
+++ b/src/server/oauth/schemas.ts
@@ -9,6 +9,7 @@ export const mcpAuthorizeSchema = z.object({
code_challenge: requiredString('code_challenge'),
code_challenge_method: requiredString('code_challenge_method'),
state: z.string().optional(),
+ scope: z.string().optional(),
});
export const mcpTokenSchema = z
@@ -43,13 +44,15 @@ export const mcpTokenSchema = z
// Optional because client/secret pair may be provided in the request body instead of the query string
client_id: z.string().optional(),
client_secret: z.string().optional(),
+ scope: z.string().optional(),
}),
)
.transform((data) => {
- const { client_id, client_secret } = data;
+ const { client_id, client_secret, scope } = data;
const clientIdSecretPair = {
clientId: client_id,
clientSecret: client_secret,
+ scope,
};
if (data.grant_type === 'authorization_code') {
@@ -91,6 +94,7 @@ export const mcpAccessTokenUserOnlySchema = z.object({
tableauServer: requiredString('tableauServer'),
// Optional because there may not be a user associated with the access token, e.g. for client credentials grant type
tableauUserId: z.string().optional(),
+ scope: z.string().optional(),
});
export const mcpAccessTokenSchema = mcpAccessTokenUserOnlySchema.extend({
diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts
new file mode 100644
index 00000000..69af33f9
--- /dev/null
+++ b/src/server/oauth/scopes.ts
@@ -0,0 +1,203 @@
+/**
+ * OAuth Scope Definitions and Utilities
+ *
+ * Defines MCP scopes for Tableau MCP server and provides utilities
+ * for scope validation and management.
+ */
+
+import { getConfig } from '../../config.js';
+import { ToolName } from '../../tools/toolName.js';
+
+/**
+ * MCP Scopes supported by the Tableau MCP server
+ *
+ * These scopes represent permissions that clients can request
+ * when authenticating with the MCP server.
+ */
+export type McpScope =
+ | 'tableau:mcp:content:read'
+ | 'tableau:mcp:datasource:read'
+ | 'tableau:mcp:workbook:read'
+ | 'tableau:mcp:view:read'
+ | 'tableau:mcp:view:download'
+ | 'tableau:mcp:pulse:read'
+ | 'tableau:mcp:insight:create';
+
+export type TableauApiScope =
+ | 'tableau:content:read'
+ | 'tableau:viz_data_service:read'
+ | 'tableau:views:download'
+ | 'tableau:insight_definitions_metrics:read'
+ | 'tableau:insight_metrics:read'
+ | 'tableau:metric_subscriptions:read'
+ | 'tableau:insights:read'
+ | 'tableau:insight_brief:create';
+
+/**
+ * Default scopes supported by the MCP server
+ *
+ * This list can be configured via environment variable or config file.
+ */
+export const DEFAULT_SCOPES_SUPPORTED: McpScope[] = [
+ 'tableau:mcp:content:read',
+ 'tableau:mcp:datasource:read',
+ 'tableau:mcp:workbook:read',
+ 'tableau:mcp:view:read',
+ 'tableau:mcp:view:download',
+ 'tableau:mcp:pulse:read',
+ 'tableau:mcp:insight:create',
+];
+
+/**
+ * Minimal default scopes suggested when no specific tool is known.
+ */
+export const DEFAULT_REQUIRED_SCOPES: McpScope[] = ['tableau:mcp:content:read'];
+
+/**
+ * Validates that a scope string is a valid MCP scope
+ */
+export function isValidScope(scope: string): scope is McpScope {
+ return DEFAULT_SCOPES_SUPPORTED.some((supported) => supported === scope);
+}
+
+const toolScopeMap: Record = {
+ 'list-datasources': {
+ mcp: ['tableau:mcp:datasource:read'],
+ api: ['tableau:content:read'],
+ },
+ 'list-workbooks': {
+ mcp: ['tableau:mcp:workbook:read'],
+ api: ['tableau:content:read'],
+ },
+ 'list-views': {
+ mcp: ['tableau:mcp:view:read'],
+ api: ['tableau:content:read'],
+ },
+ 'query-datasource': {
+ mcp: ['tableau:mcp:datasource:read'],
+ api: ['tableau:viz_data_service:read'],
+ },
+ 'get-datasource-metadata': {
+ mcp: ['tableau:mcp:datasource:read'],
+ api: ['tableau:content:read', 'tableau:viz_data_service:read'],
+ },
+ 'get-workbook': {
+ mcp: ['tableau:mcp:workbook:read'],
+ api: ['tableau:content:read'],
+ },
+ 'get-view-data': {
+ mcp: ['tableau:mcp:view:download'],
+ api: ['tableau:views:download'],
+ },
+ 'get-view-image': {
+ mcp: ['tableau:mcp:view:download'],
+ api: ['tableau:views:download'],
+ },
+ 'list-all-pulse-metric-definitions': {
+ mcp: ['tableau:mcp:pulse:read'],
+ api: ['tableau:insight_definitions_metrics:read'],
+ },
+ 'list-pulse-metric-definitions-from-definition-ids': {
+ mcp: ['tableau:mcp:pulse:read'],
+ api: ['tableau:insight_definitions_metrics:read'],
+ },
+ 'list-pulse-metrics-from-metric-definition-id': {
+ mcp: ['tableau:mcp:pulse:read'],
+ api: ['tableau:insight_definitions_metrics:read'],
+ },
+ 'list-pulse-metrics-from-metric-ids': {
+ mcp: ['tableau:mcp:pulse:read'],
+ api: ['tableau:insight_metrics:read'],
+ },
+ 'list-pulse-metric-subscriptions': {
+ mcp: ['tableau:mcp:pulse:read'],
+ api: ['tableau:metric_subscriptions:read'],
+ },
+ 'generate-pulse-metric-value-insight-bundle': {
+ mcp: ['tableau:mcp:insight:create'],
+ api: ['tableau:insights:read'],
+ },
+ 'generate-pulse-insight-brief': {
+ mcp: ['tableau:mcp:insight:create'],
+ api: ['tableau:insight_brief:create'],
+ },
+ 'search-content': {
+ mcp: ['tableau:mcp:content:read'],
+ api: ['tableau:content:read'],
+ },
+};
+
+/**
+ * Parses a space-separated scope string into an array of scopes
+ *
+ * @param scopeString - Space-separated scope string (e.g., "read write")
+ * @returns Array of valid scope strings
+ */
+export function parseScopes(scopeString: string | undefined): string[] {
+ if (!scopeString || scopeString.trim() === '') {
+ return [];
+ }
+ const scopes = scopeString
+ .split(/\s+/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+
+ return [...new Set(scopes)];
+}
+
+/**
+ * Validates an array of scopes against the supported scopes
+ *
+ * @param requestedScopes - Array of scope strings to validate
+ * @param supportedScopes - Array of supported scope strings
+ * @returns Object with valid and invalid scopes
+ */
+export function validateScopes(
+ requestedScopes: string[],
+ supportedScopes: string[],
+): { valid: string[]; invalid: string[] } {
+ const valid: string[] = [];
+ const invalid: string[] = [];
+
+ for (const scope of requestedScopes) {
+ if (supportedScopes.includes(scope)) {
+ valid.push(scope);
+ } else {
+ invalid.push(scope);
+ }
+ }
+
+ return { valid, invalid };
+}
+
+/**
+ * Determines the required scopes for a given MCP endpoint/tool
+ *
+ * This function maps MCP tools to their required scopes.
+ * This is used to provide scope guidance in WWW-Authenticate headers.
+ *
+ * @param endpoint - The MCP endpoint or tool name
+ * @returns Array of required scopes for the endpoint
+ */
+export function getRequiredScopesForTool(toolName: ToolName): McpScope[] {
+ const oauthConfig = getConfig().oauth;
+ if (!oauthConfig || !oauthConfig.enforceScopes) {
+ return [];
+ }
+
+ return toolScopeMap[toolName].mcp;
+}
+
+export function getRequiredApiScopesForTool(toolName: ToolName): TableauApiScope[] {
+ return toolScopeMap[toolName].api;
+}
+
+/**
+ * Formats scopes as a space-separated string (RFC 6749 format)
+ *
+ * @param scopes - Array of scope strings
+ * @returns Space-separated scope string
+ */
+export function formatScopes(scopes: string[]): string {
+ return scopes.join(' ');
+}
diff --git a/src/server/oauth/token.ts b/src/server/oauth/token.ts
index 45c13237..36b2a9bf 100644
--- a/src/server/oauth/token.ts
+++ b/src/server/oauth/token.ts
@@ -11,6 +11,7 @@ import { setLongTimeout } from '../../utils/setLongTimeout.js';
import { generateCodeChallenge } from './generateCodeChallenge.js';
import { AUDIENCE } from './provider.js';
import { mcpTokenSchema } from './schemas.js';
+import { formatScopes, parseScopes, validateScopes } from './scopes.js';
import { AuthorizationCode, ClientCredentials, RefreshTokenData, UserAndTokens } from './types.js';
/**
@@ -88,6 +89,7 @@ export function token(
server: authCode.server,
clientId: authCode.clientId,
tokens: authCode.tokens,
+ scopes: authCode.scopes,
expiresAt: Math.floor((Date.now() + config.oauth.refreshTokenTimeoutMs) / 1000),
tableauClientId: authCode.tableauClientId,
});
@@ -104,10 +106,39 @@ export function token(
token_type: 'Bearer',
expires_in: config.oauth.accessTokenTimeoutMs / 1000,
refresh_token: refreshTokenId,
+ scope: formatScopes(authCode.scopes),
});
return;
}
case 'client_credentials': {
+ const { enforceScopes, requiredScopes, scopesSupported } = config.oauth;
+ const requestedScopes = parseScopes(result.data.scope);
+ const { valid: validScopes, invalid: invalidScopes } = validateScopes(
+ requestedScopes,
+ scopesSupported,
+ );
+
+ if (invalidScopes.length > 0) {
+ res.status(400).json({
+ error: 'invalid_scope',
+ error_description: `Unsupported scopes: ${invalidScopes.join(', ')}`,
+ });
+ return;
+ }
+
+ const scopesToGrant =
+ enforceScopes && validScopes.length === 0 ? requiredScopes : validScopes;
+ if (enforceScopes) {
+ const missingRequiredScopes = requiredScopes.filter((s) => !scopesToGrant.includes(s));
+ if (missingRequiredScopes.length > 0) {
+ res.status(400).json({
+ error: 'invalid_scope',
+ error_description: `Missing required scopes: ${missingRequiredScopes.join(', ')}`,
+ });
+ return;
+ }
+ }
+
// Generate access token for client credentials grant type.
// Refresh token is not supported for client credentials grant type.
// https://www.rfc-editor.org/rfc/rfc6749#section-4.4.3
@@ -116,6 +147,7 @@ export function token(
clientId: clientCredentialsResult.value.clientId,
server: config.server,
},
+ scopesToGrant,
publicKey,
);
@@ -123,6 +155,7 @@ export function token(
access_token: accessToken,
token_type: 'Bearer',
expires_in: config.oauth.accessTokenTimeoutMs / 1000,
+ scope: formatScopes(scopesToGrant),
});
return;
}
@@ -158,6 +191,7 @@ export function token(
clientId: tokenData.clientId,
server: tokenData.server,
tokens: tokenData.tokens,
+ scopes: tokenData.scopes,
},
publicKey,
);
@@ -178,6 +212,7 @@ export function token(
refreshToken: newTableauRefreshToken,
expiresInSeconds,
},
+ scopes: tokenData.scopes,
},
publicKey,
);
@@ -192,6 +227,7 @@ export function token(
server: tokenData.server,
clientId: tokenData.clientId,
tokens: tokenData.tokens,
+ scopes: tokenData.scopes,
expiresAt: Math.floor((Date.now() + config.oauth.refreshTokenTimeoutMs) / 1000),
tableauClientId: tokenData.tableauClientId,
});
@@ -201,6 +237,7 @@ export function token(
token_type: 'Bearer',
expires_in: config.oauth.accessTokenTimeoutMs / 1000,
refresh_token: refreshTokenId,
+ scope: formatScopes(tokenData.scopes),
});
return;
}
@@ -235,6 +272,7 @@ async function createAccessToken(tokenData: UserAndTokens, publicKey: KeyObject)
exp: Math.floor((Date.now() + config.oauth.accessTokenTimeoutMs) / 1000),
aud: AUDIENCE,
iss: config.oauth.issuer,
+ scope: formatScopes(tokenData.scopes),
...(config.auth === 'oauth'
? {
tableauAccessToken: tokenData.tokens.accessToken,
@@ -254,6 +292,7 @@ async function createAccessToken(tokenData: UserAndTokens, publicKey: KeyObject)
async function createClientCredentialsAccessToken(
clientCredentials: ClientCredentials,
+ scopes: string[],
publicKey: KeyObject,
): Promise {
const config = getConfig();
@@ -265,6 +304,7 @@ async function createClientCredentialsAccessToken(
exp: Math.floor((Date.now() + config.oauth.accessTokenTimeoutMs) / 1000),
aud: AUDIENCE,
iss: config.oauth.issuer,
+ scope: formatScopes(scopes),
});
const jwe = await new CompactEncrypt(new TextEncoder().encode(payload))
diff --git a/src/server/oauth/types.ts b/src/server/oauth/types.ts
index 8420c7cd..99a24906 100644
--- a/src/server/oauth/types.ts
+++ b/src/server/oauth/types.ts
@@ -21,6 +21,7 @@ export type PendingAuthorization = {
tableauState: string;
tableauClientId: string;
tableauCodeVerifier: string;
+ scopes: string[];
};
export type ClientCredentials = {
@@ -33,6 +34,7 @@ export type UserAndTokens = {
clientId: string;
server: string;
tokens: Tokens;
+ scopes: string[];
};
export type AuthorizationCode = UserAndTokens & {
diff --git a/src/tools/contentExploration/searchContent.ts b/src/tools/contentExploration/searchContent.ts
index a99444f4..4c9f9b69 100644
--- a/src/tools/contentExploration/searchContent.ts
+++ b/src/tools/contentExploration/searchContent.ts
@@ -10,6 +10,7 @@ import {
} from '../../sdks/tableau/types/contentExploration.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { Tool } from '../tool.js';
import {
@@ -80,7 +81,7 @@ This tool searches across all supported content types for objects relevant to th
config,
requestId,
server,
- jwtScopes: ['tableau:content:read'],
+ jwtScopes: getRequiredApiScopesForTool('search-content'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts b/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts
index 08fdc5ba..781ee402 100644
--- a/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts
+++ b/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts
@@ -7,6 +7,7 @@ import { useRestApi } from '../../restApiInstance.js';
import { GraphQLResponse } from '../../sdks/tableau/apis/metadataApi.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js';
import { resourceAccessChecker } from '../resourceAccessChecker.js';
@@ -143,7 +144,7 @@ export const getGetDatasourceMetadataTool = (server: Server): Tool {
diff --git a/src/tools/listDatasources/listDatasources.ts b/src/tools/listDatasources/listDatasources.ts
index 037c995c..39c45be9 100644
--- a/src/tools/listDatasources/listDatasources.ts
+++ b/src/tools/listDatasources/listDatasources.ts
@@ -7,6 +7,7 @@ import { useRestApi } from '../../restApiInstance.js';
import { DataSource } from '../../sdks/tableau/types/dataSource.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { paginate } from '../../utils/paginate.js';
import { genericFilterDescription } from '../genericFilterDescription.js';
@@ -95,7 +96,7 @@ export const getListDatasourcesTool = (server: Server): Tool {
diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts
index 1059bfc9..4dc14f5a 100644
--- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts
+++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts
@@ -10,6 +10,7 @@ import {
} from '../../../sdks/tableau/types/pulse.js';
import { Server } from '../../../server.js';
import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js';
import { Tool } from '../../tool.js';
import { getPulseDisabledError } from '../getPulseDisabledError.js';
@@ -235,7 +236,7 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I
config,
requestId,
server,
- jwtScopes: ['tableau:insight_brief:create'],
+ jwtScopes: getRequiredApiScopesForTool('generate-pulse-insight-brief'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) =>
diff --git a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts
index be057fcf..4b178d8d 100644
--- a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts
+++ b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts
@@ -12,6 +12,7 @@ import {
} from '../../../sdks/tableau/types/pulse.js';
import { Server } from '../../../server.js';
import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js';
import { Tool } from '../../tool.js';
import { getPulseDisabledError } from '../getPulseDisabledError.js';
@@ -186,7 +187,7 @@ Generate an insight bundle for the current aggregated value for Pulse Metric usi
config,
requestId,
server,
- jwtScopes: ['tableau:insights:read'],
+ jwtScopes: getRequiredApiScopesForTool('generate-pulse-metric-value-insight-bundle'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) =>
diff --git a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts
index 761d8d44..13b2907b 100644
--- a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts
+++ b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts
@@ -10,6 +10,7 @@ import {
} from '../../../sdks/tableau/types/pulse.js';
import { Server } from '../../../server.js';
import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js';
import { pulsePaginate } from '../../../utils/paginate.js';
import { Tool } from '../../tool.js';
@@ -73,7 +74,7 @@ Retrieves a list of all published Pulse Metric Definitions using the Tableau RES
config,
requestId,
server,
- jwtScopes: ['tableau:insight_definitions_metrics:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-all-pulse-metric-definitions'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts
index 38aa4fd9..5c44c716 100644
--- a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts
+++ b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts
@@ -6,6 +6,7 @@ import { useRestApi } from '../../../restApiInstance.js';
import { pulseMetricDefinitionViewEnum } from '../../../sdks/tableau/types/pulse.js';
import { Server } from '../../../server.js';
import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js';
import { Tool } from '../../tool.js';
import { constrainPulseDefinitions } from '../constrainPulseDefinitions.js';
@@ -73,7 +74,9 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API
config,
requestId,
server,
- jwtScopes: ['tableau:insight_definitions_metrics:read'],
+ jwtScopes: getRequiredApiScopesForTool(
+ 'list-pulse-metric-definitions-from-definition-ids',
+ ),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts
index 5c330115..3e22928e 100644
--- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts
+++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts
@@ -5,6 +5,7 @@ import { useRestApi } from '../../../restApiInstance.js';
import { PulseMetricSubscription } from '../../../sdks/tableau/types/pulse.js';
import { Server } from '../../../server.js';
import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js';
import { getExceptionMessage } from '../../../utils/getExceptionMessage.js';
import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js';
import { RestApiArgs } from '../../resourceAccessChecker.js';
@@ -47,7 +48,7 @@ Retrieves a list of published Pulse Metric Subscriptions for the current user us
config,
requestId,
server,
- jwtScopes: ['tableau:metric_subscriptions:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-pulse-metric-subscriptions'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
@@ -109,7 +110,7 @@ export async function constrainPulseMetricSubscriptions({
config,
requestId,
server,
- jwtScopes: ['tableau:insight_metrics:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-pulse-metrics-from-metric-ids'),
signal,
callback: async (restApi) => {
return await restApi.pulseMethods.listPulseMetricsFromMetricIds(
diff --git a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts
index 45a1fae0..d63059be 100644
--- a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts
+++ b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts
@@ -7,6 +7,7 @@ import { PulseDisabledError } from '../../../sdks/tableau/methods/pulseMethods.j
import { PulseMetric } from '../../../sdks/tableau/types/pulse.js';
import { Server } from '../../../server.js';
import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js';
import { Tool } from '../../tool.js';
import { constrainPulseMetrics } from '../constrainPulseMetrics.js';
@@ -55,7 +56,7 @@ Retrieves a list of published Pulse Metrics from a Pulse Metric Definition using
config,
requestId,
server,
- jwtScopes: ['tableau:insight_definitions_metrics:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-pulse-metrics-from-metric-definition-id'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts
index ffc4f278..687038d7 100644
--- a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts
+++ b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts
@@ -5,6 +5,7 @@ import { getConfig } from '../../../config.js';
import { useRestApi } from '../../../restApiInstance.js';
import { Server } from '../../../server.js';
import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js';
import { Tool } from '../../tool.js';
import { constrainPulseMetrics } from '../constrainPulseMetrics.js';
@@ -54,7 +55,7 @@ Retrieves a list of published Pulse Metrics from a list of metric IDs using the
config,
requestId,
server,
- jwtScopes: ['tableau:insight_metrics:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-pulse-metrics-from-metric-ids'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/queryDatasource/queryDatasource.ts b/src/tools/queryDatasource/queryDatasource.ts
index 89ac82db..d13d4fcf 100644
--- a/src/tools/queryDatasource/queryDatasource.ts
+++ b/src/tools/queryDatasource/queryDatasource.ts
@@ -14,6 +14,7 @@ import {
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
import { TableauAuthInfo } from '../../server/oauth/schemas.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { getResultForTableauVersion } from '../../utils/isTableauVersionAtLeast.js';
import { Provider } from '../../utils/provider.js';
@@ -137,7 +138,7 @@ export const getQueryDatasourceTool = (
config,
requestId,
server,
- jwtScopes: ['tableau:viz_data_service:read'],
+ jwtScopes: getRequiredApiScopesForTool('query-datasource'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts
index b0bb9e79..8627c464 100644
--- a/src/tools/resourceAccessChecker.ts
+++ b/src/tools/resourceAccessChecker.ts
@@ -6,6 +6,7 @@ import { DataSource } from '../sdks/tableau/types/dataSource.js';
import { View } from '../sdks/tableau/types/view.js';
import { Workbook } from '../sdks/tableau/types/workbook.js';
import { Server } from '../server.js';
+import { getRequiredApiScopesForTool } from '../server/oauth/scopes.js';
import { getExceptionMessage } from '../utils/getExceptionMessage.js';
type AllowedResult =
@@ -173,7 +174,7 @@ class ResourceAccessChecker {
config,
requestId,
server,
- jwtScopes: ['tableau:content:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-datasources'),
signal,
callback: async (restApi) =>
await restApi.datasourcesMethods.queryDatasource({
@@ -264,7 +265,7 @@ class ResourceAccessChecker {
config,
requestId,
server,
- jwtScopes: ['tableau:content:read'],
+ jwtScopes: getRequiredApiScopesForTool('get-workbook'),
signal,
callback: async (restApi) =>
await restApi.workbooksMethods.getWorkbook({
@@ -345,7 +346,7 @@ class ResourceAccessChecker {
config,
requestId,
server,
- jwtScopes: ['tableau:content:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-views'),
signal,
callback: async (restApi) => {
return await restApi.viewsMethods.getView({
diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts
index d75d0792..b4cd9ca9 100644
--- a/src/tools/views/getViewData.ts
+++ b/src/tools/views/getViewData.ts
@@ -6,6 +6,7 @@ import { getConfig } from '../../config.js';
import { useRestApi } from '../../restApiInstance.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { resourceAccessChecker } from '../resourceAccessChecker.js';
import { Tool } from '../tool.js';
@@ -60,7 +61,7 @@ export const getGetViewDataTool = (server: Server): Tool =>
config,
requestId,
server,
- jwtScopes: ['tableau:views:download'],
+ jwtScopes: getRequiredApiScopesForTool('get-view-data'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts
index 0b77771d..25ca5652 100644
--- a/src/tools/views/getViewImage.ts
+++ b/src/tools/views/getViewImage.ts
@@ -6,6 +6,7 @@ import { getConfig } from '../../config.js';
import { useRestApi } from '../../restApiInstance.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { convertPngDataToToolResult } from '../convertPngDataToToolResult.js';
import { resourceAccessChecker } from '../resourceAccessChecker.js';
@@ -63,7 +64,7 @@ export const getGetViewImageTool = (server: Server): Tool =
config,
requestId,
server,
- jwtScopes: ['tableau:views:download'],
+ jwtScopes: getRequiredApiScopesForTool('get-view-image'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/views/listViews.ts b/src/tools/views/listViews.ts
index e8166b16..257b35e0 100644
--- a/src/tools/views/listViews.ts
+++ b/src/tools/views/listViews.ts
@@ -7,6 +7,7 @@ import { useRestApi } from '../../restApiInstance.js';
import { View } from '../../sdks/tableau/types/view.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { paginate } from '../../utils/paginate.js';
import { genericFilterDescription } from '../genericFilterDescription.js';
@@ -85,7 +86,7 @@ export const getListViewsTool = (server: Server): Tool => {
config,
requestId,
server,
- jwtScopes: ['tableau:content:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-views'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/workbooks/getWorkbook.ts b/src/tools/workbooks/getWorkbook.ts
index 84cc9617..41310fbf 100644
--- a/src/tools/workbooks/getWorkbook.ts
+++ b/src/tools/workbooks/getWorkbook.ts
@@ -7,6 +7,7 @@ import { useRestApi } from '../../restApiInstance.js';
import { Workbook } from '../../sdks/tableau/types/workbook.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { resourceAccessChecker } from '../resourceAccessChecker.js';
import { ConstrainedResult, Tool } from '../tool.js';
@@ -61,7 +62,7 @@ export const getGetWorkbookTool = (server: Server): Tool =>
config,
requestId,
server,
- jwtScopes: ['tableau:content:read'],
+ jwtScopes: getRequiredApiScopesForTool('get-workbook'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/tools/workbooks/listWorkbooks.ts b/src/tools/workbooks/listWorkbooks.ts
index f4803268..ec593f01 100644
--- a/src/tools/workbooks/listWorkbooks.ts
+++ b/src/tools/workbooks/listWorkbooks.ts
@@ -7,6 +7,7 @@ import { useRestApi } from '../../restApiInstance.js';
import { Workbook } from '../../sdks/tableau/types/workbook.js';
import { Server } from '../../server.js';
import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js';
+import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js';
import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js';
import { paginate } from '../../utils/paginate.js';
import { genericFilterDescription } from '../genericFilterDescription.js';
@@ -82,7 +83,7 @@ export const getListWorkbooksTool = (server: Server): Tool
config,
requestId,
server,
- jwtScopes: ['tableau:content:read'],
+ jwtScopes: getRequiredApiScopesForTool('list-workbooks'),
signal,
authInfo: getTableauAuthInfo(authInfo),
callback: async (restApi) => {
diff --git a/src/utils/axios.ts b/src/utils/axios.ts
index de5c6824..f2c3edec 100644
--- a/src/utils/axios.ts
+++ b/src/utils/axios.ts
@@ -1,8 +1,4 @@
-import axios, {
- AxiosRequestConfig,
- AxiosResponse,
- isAxiosError,
-} from '../../node_modules/axios/index.js';
+import axios, { AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';
export function getStringResponseHeader(
headers: AxiosResponse['headers'],
@@ -15,7 +11,5 @@ export function getStringResponseHeader(
return '';
}
-// Our dependency on Axios is indirect through Zodios.
-// Zodios doesn't re-export the exports of axios, so we need to import it haphazardly through node_modules.
-// This re-export is only to prevent import clutter in the codebase.
+// We re-export Axios types to avoid import clutter in the codebase.
export { axios, AxiosRequestConfig, AxiosResponse, isAxiosError };
diff --git a/tests/e2e/workbooks/listWorkbooks.test.ts b/tests/e2e/workbooks/listWorkbooks.test.ts
index 3007ff3a..bde9b603 100644
--- a/tests/e2e/workbooks/listWorkbooks.test.ts
+++ b/tests/e2e/workbooks/listWorkbooks.test.ts
@@ -34,11 +34,13 @@ describe('list-workbooks', () => {
const workbooks = await callTool('list-workbooks', {
env,
schema: z.array(workbookSchema),
- toolArgs: { filter: 'name:eq:Super*' },
+ toolArgs: { filter: 'name:eq:Superstore' },
});
- expect(workbooks).toHaveLength(1);
- expect(workbooks[0]).toMatchObject({
+ expect(workbooks.length).greaterThan(0);
+ const workbook = workbooks.find((candidate) => candidate.name === 'Superstore');
+
+ expect(workbook).toMatchObject({
id: superstore.id,
name: 'Superstore',
defaultViewId: superstore.defaultViewId,
diff --git a/tests/oauth/clientCredentialsGrant.test.ts b/tests/oauth/clientCredentialsGrant.test.ts
index d07384a2..0acd5b08 100644
--- a/tests/oauth/clientCredentialsGrant.test.ts
+++ b/tests/oauth/clientCredentialsGrant.test.ts
@@ -65,6 +65,7 @@ describe('client credentials grant type', () => {
refresh_token: undefined,
token_type: 'Bearer',
expires_in: 3600,
+ scope: '',
});
});
@@ -88,6 +89,7 @@ describe('client credentials grant type', () => {
refresh_token: undefined,
token_type: 'Bearer',
expires_in: 3600,
+ scope: '',
});
});
diff --git a/tests/oauth/exchangeAuthzCodeForAccessToken.ts b/tests/oauth/exchangeAuthzCodeForAccessToken.ts
index 3bd7e9d7..966c127f 100644
--- a/tests/oauth/exchangeAuthzCodeForAccessToken.ts
+++ b/tests/oauth/exchangeAuthzCodeForAccessToken.ts
@@ -50,6 +50,7 @@ export async function exchangeAuthzCodeForAccessToken(app: express.Application):
refresh_token: expect.any(String),
token_type: 'Bearer',
expires_in: 3600,
+ scope: '',
});
return tokenResponse.body;
diff --git a/tests/oauth/oauth.test.ts b/tests/oauth/oauth.test.ts
index 89722a33..178f54c0 100644
--- a/tests/oauth/oauth.test.ts
+++ b/tests/oauth/oauth.test.ts
@@ -94,7 +94,7 @@ describe('OAuth', () => {
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
scopes_supported: [],
- token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
subject_types_supported: ['public'],
client_id_metadata_document_supported: true,
});
diff --git a/tests/oauth/refreshTokenGrant.test.ts b/tests/oauth/refreshTokenGrant.test.ts
index c8a1e9d6..65b2d3cc 100644
--- a/tests/oauth/refreshTokenGrant.test.ts
+++ b/tests/oauth/refreshTokenGrant.test.ts
@@ -120,6 +120,7 @@ describe('refresh token grant type', () => {
refresh_token: expect.any(String),
token_type: 'Bearer',
expires_in: 3600,
+ scope: '',
});
// Verify that the refresh token is rotated
diff --git a/tests/testEnv.ts b/tests/testEnv.ts
index 45d57a26..76940beb 100644
--- a/tests/testEnv.ts
+++ b/tests/testEnv.ts
@@ -14,6 +14,9 @@ export function setEnv(): void {
}
dotenv.config({ path: 'tests/.env', override: true });
+ if (!process.env.OAUTH_DISABLE_SCOPES) {
+ process.env.OAUTH_DISABLE_SCOPES = 'true';
+ }
}
export function resetEnv(): void {
diff --git a/types/process-env.d.ts b/types/process-env.d.ts
index 213c8cce..cee4fe2f 100644
--- a/types/process-env.d.ts
+++ b/types/process-env.d.ts
@@ -49,9 +49,14 @@ export interface ProcessEnvEx {
OAUTH_REDIRECT_URI: string | undefined;
OAUTH_LOCK_SITE: string | undefined;
OAUTH_CLIENT_ID_SECRET_PAIRS: string | undefined;
+ OAUTH_SCOPES_SUPPORTED: string | undefined;
+ OAUTH_REQUIRED_SCOPES: string | undefined;
+ OAUTH_DISABLE_SCOPES: string | undefined;
OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: string | undefined;
OAUTH_ACCESS_TOKEN_TIMEOUT_MS: string | undefined;
OAUTH_REFRESH_TOKEN_TIMEOUT_MS: string | undefined;
+ TELEMETRY_PROVIDER: string | undefined;
+ TELEMETRY_PROVIDER_CONFIG: string | undefined;
PRODUCT_TELEMETRY_ENABLED: string | undefined;
PRODUCT_TELEMETRY_ENDPOINT: string | undefined;
}