Skip to main content
Glama
token.ts11.5 kB
import { KeyObject, randomBytes, timingSafeEqual } from 'crypto'; import express from 'express'; import { CompactEncrypt } from 'jose'; import { Err, Ok, Result } from 'ts-results-es'; import { fromError } from 'zod-validation-error'; import { getConfig } from '../../config.js'; import { getTokenResult } from '../../sdks/tableau-oauth/methods.js'; import { TableauAccessToken } from '../../sdks/tableau-oauth/types.js'; import { setLongTimeout } from '../../utils/setLongTimeout.js'; import { generateCodeChallenge } from './generateCodeChallenge.js'; import { AUDIENCE } from './provider.js'; import { mcpTokenSchema } from './schemas.js'; import { AuthorizationCode, ClientCredentials, RefreshTokenData, UserAndTokens } from './types.js'; /** * OAuth 2.1 Token Endpoint * * Exchanges authorization code for access token. * Verifies PKCE code_verifier matches the original challenge. * Returns JWE containing tokens for API access. */ export function token( app: express.Application, authorizationCodes: Map<string, AuthorizationCode>, refreshTokens: Map<string, RefreshTokenData>, publicKey: KeyObject, ): void { const config = getConfig(); app.post('/oauth/token', async (req, res) => { const result = mcpTokenSchema.safeParse(req.body); if (!result.success) { res.status(400).json({ error: 'invalid_request', error_description: fromError(result.error).toString(), }); return; } const clientCredentialsResult = verifyClientCredentials({ required: result.data.grantType === 'client_credentials', clientId: result.data.clientId, clientSecret: result.data.clientSecret, clientIdSecretPairs: getConfig().oauth.clientIdSecretPairs, authorizationHeader: req.headers.authorization, }); if (clientCredentialsResult.isErr()) { res.status(401).json({ error: 'invalid_client', error_description: clientCredentialsResult.error, }); return; } try { switch (result.data.grantType) { case 'authorization_code': { // Handle authorization code exchange const { code, codeVerifier } = result.data; const authCode = authorizationCodes.get(code); if (!authCode || authCode.expiresAt < Math.floor(Date.now() / 1000)) { authorizationCodes.delete(code); res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid or expired authorization code', }); return; } // Verify PKCE const challengeFromVerifier = generateCodeChallenge(codeVerifier); if (challengeFromVerifier !== authCode.codeChallenge) { res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid code verifier', }); return; } // Generate tokens const refreshTokenId = randomBytes(32).toString('hex'); const accessToken = await createAccessToken(authCode, publicKey); refreshTokens.set(refreshTokenId, { user: authCode.user, server: authCode.server, clientId: authCode.clientId, tokens: authCode.tokens, expiresAt: Math.floor((Date.now() + config.oauth.refreshTokenTimeoutMs) / 1000), tableauClientId: authCode.tableauClientId, }); setLongTimeout( () => refreshTokens.delete(refreshTokenId), config.oauth.refreshTokenTimeoutMs, ); authorizationCodes.delete(code); res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: config.oauth.accessTokenTimeoutMs / 1000, refresh_token: refreshTokenId, }); return; } case 'client_credentials': { // 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 const accessToken = await createClientCredentialsAccessToken( { clientId: clientCredentialsResult.value.clientId, server: config.server, }, publicKey, ); res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: config.oauth.accessTokenTimeoutMs / 1000, }); return; } case 'refresh_token': { // Handle refresh token const { refreshToken } = result.data; const tokenData = refreshTokens.get(refreshToken); if (!tokenData || tokenData.expiresAt < Math.floor(Date.now() / 1000)) { // Refresh token is expired refreshTokens.delete(refreshToken); res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid or expired refresh token', }); return; } let accessToken: string; const { refreshToken: tableauRefreshToken } = tokenData.tokens; const tokensResult = await exchangeRefreshToken( tokenData.server, tableauRefreshToken, tokenData.tableauClientId, ); if (tokensResult.isErr()) { // If the refresh token exchange fails, reuse the existing Tableau access token // which may or may not be expired. accessToken = await createAccessToken( { user: tokenData.user, clientId: tokenData.clientId, server: tokenData.server, tokens: tokenData.tokens, }, publicKey, ); } else { const { accessToken: newTableauAccessToken, refreshToken: newTableauRefreshToken, expiresInSeconds, } = tokensResult.value; accessToken = await createAccessToken( { user: tokenData.user, clientId: tokenData.clientId, server: tokenData.server, tokens: { accessToken: newTableauAccessToken, refreshToken: newTableauRefreshToken, expiresInSeconds, }, }, publicKey, ); } // Rotate the refresh token and extend its expiration time refreshTokens.delete(refreshToken); const refreshTokenId = randomBytes(32).toString('hex'); refreshTokens.set(refreshTokenId, { user: tokenData.user, server: tokenData.server, clientId: tokenData.clientId, tokens: tokenData.tokens, expiresAt: Math.floor((Date.now() + config.oauth.refreshTokenTimeoutMs) / 1000), tableauClientId: tokenData.tableauClientId, }); res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: config.oauth.accessTokenTimeoutMs / 1000, refresh_token: refreshTokenId, }); return; } } } catch (error) { console.error('Token endpoint error:', error); res.status(500).json({ error: 'server_error', error_description: 'Internal server error', }); return; } }); } /** * Creates JWE access token containing credentials * * @param tokenData - token data * @param publicKey - public key for encrypting the token * @returns Encrypted JWE token for MCP authentication */ async function createAccessToken(tokenData: UserAndTokens, publicKey: KeyObject): Promise<string> { const config = getConfig(); const payload = JSON.stringify({ sub: tokenData.user.name, clientId: tokenData.clientId, tableauServer: tokenData.server, tableauUserId: tokenData.user.id, iat: Math.floor(Date.now() / 1000), exp: Math.floor((Date.now() + config.oauth.accessTokenTimeoutMs) / 1000), aud: AUDIENCE, iss: config.oauth.issuer, ...(config.auth === 'oauth' ? { tableauAccessToken: tokenData.tokens.accessToken, tableauRefreshToken: tokenData.tokens.refreshToken, tableauExpiresAt: Math.floor(Date.now() / 1000) + tokenData.tokens.expiresInSeconds, tableauUserId: tokenData.user.id, } : {}), }); const jwe = await new CompactEncrypt(new TextEncoder().encode(payload)) .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) .encrypt(publicKey); return jwe; } async function createClientCredentialsAccessToken( clientCredentials: ClientCredentials, publicKey: KeyObject, ): Promise<string> { const config = getConfig(); const payload = JSON.stringify({ sub: clientCredentials.clientId, clientId: clientCredentials.clientId, tableauServer: clientCredentials.server, iat: Math.floor(Date.now() / 1000), exp: Math.floor((Date.now() + config.oauth.accessTokenTimeoutMs) / 1000), aud: AUDIENCE, iss: config.oauth.issuer, }); const jwe = await new CompactEncrypt(new TextEncoder().encode(payload)) .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) .encrypt(publicKey); return jwe; } async function exchangeRefreshToken( server: string, refreshToken: string, clientId: string, ): Promise<Result<TableauAccessToken, string>> { try { const result = await getTokenResult(server, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, site_namespace: '', }); return Ok(result); } catch { return Err('Failed to exchange refresh token'); } } function verifyClientCredentials({ required, clientId, clientSecret, clientIdSecretPairs, authorizationHeader, }: { required: boolean; clientId: string | undefined; clientSecret: string | undefined; clientIdSecretPairs: Record<string, string> | null; authorizationHeader: string | undefined; }): Result<{ clientId: string }, string> { if (!clientId && !clientSecret) { if (required) { if (!authorizationHeader) { return Err('Authorization header is required'); } const [type, credentials] = authorizationHeader.split(' '); if (type !== 'Basic') { return Err('Invalid authorization type'); } [clientId, clientSecret] = Buffer.from(credentials, 'base64').toString().split(':'); if (!clientId || !clientSecret) { return Err('Invalid client credentials'); } } } if (!required) { return Ok({ clientId: '' }); } if (!clientId) { return Err('Client ID is required'); } if (!clientSecret) { return Err('Client secret is required'); } const expectedClientSecret = clientIdSecretPairs?.[clientId]; if (!expectedClientSecret || clientSecret.length !== expectedClientSecret.length) { return Err('Invalid client credentials'); } const textEncoder = new TextEncoder(); const clientSecretBuffer = textEncoder.encode(clientSecret); const expectedClientSecretBuffer = textEncoder.encode(expectedClientSecret); if ( clientSecretBuffer.byteLength !== expectedClientSecretBuffer.byteLength || !timingSafeEqual(clientSecretBuffer, expectedClientSecretBuffer) ) { return Err('Invalid client credentials'); } return Ok({ clientId }); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/datalabs89/tableau-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server