Skip to content

feat: authentication flow for Private Cloud #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ npx @auth0/auth0-mcp-server init

This will start the device authorization flow, allowing you to log in to your Auth0 account and select the tenant you want to use.

> [!NOTE]
> Authenticating using device authorization flow is not supported for **private cloud** tenants.
> Private Cloud users should authenticate with client credentials.
>
> ```bash
> npx @auth0/auth0-mcp-server init --auth0-domain <auth0-domain> --auth0-client-id <auth0-client-id> --auth0-client-secret <auth0-client-secret>
> ```

> [!IMPORTANT]
> The `init` command needs to be run whenever:
>
Expand All @@ -281,6 +289,9 @@ This will start the device authorization flow, allowing you to log in to your Au
>
> The `run` command will automatically check for token validity before starting the server and will provide helpful error messages if authentication is needed.

> [!NOTE]
> Using the MCP Server will consume Management API rate limits according to the subscription plan. Refer to the [Rate Limit Policy](https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy) for more information.

### Session Management

To see information about your current authentication session:
Expand Down
105 changes: 105 additions & 0 deletions src/auth/client-credentials-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import chalk from 'chalk';
import { cliOutput, getTenantFromToken } from '../utils/terminal.js';
import { log, logError } from '../utils/logger.js';
import { keychain } from '../utils/keychain.js';

/**
* Interface for client credentials configuration
*/
export interface ClientCredentialsConfig {
auth0Domain: string;
auth0ClientId: string;
auth0ClientSecret: string;
audience?: string;
scopes?: string[];
}

/**
* Request authorization using client credentials flow
*
* This method is primarily designed for Private Cloud users who cannot use the
* device authorization flow. It uses client credentials flow to obtain an access token.
*
* @param {ClientCredentialsConfig} config - Configuration for client credentials flow
* @returns {Promise<void>}
*/
export async function requestClientCredentialsAuthorization(
config: ClientCredentialsConfig
): Promise<void> {
log('Initiating client credentials flow authentication...');

try {
const body: Record<string, string> = {
client_id: config.auth0ClientId,
client_secret: config.auth0ClientSecret,
grant_type: 'client_credentials',
};

// Set audience if provided, otherwise use a default based on the domain
const audience = config.audience || `https://${config.auth0Domain}/api/v2/`;
body.audience = audience;

// Add scopes if provided
if (config.scopes && config.scopes.length > 0) {
body.scope = config.scopes.join(' ');
}

// Make the token request
const response = await fetch(`https://${config.auth0Domain}/oauth/token`, {
method: 'POST',
body: new URLSearchParams(body),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});

const tokenSet = await response.json();

if (tokenSet.error) {
logError('Authentication error:', tokenSet.error_description || tokenSet.error);
cliOutput(`\n${chalk.red('✗')} Client credentials authentication failed.\n`);
process.exit(1);
}

// Store the token information
await storeTokenInfo(tokenSet, config.auth0Domain);

cliOutput(
`\n${chalk.green('✓')} Successfully authenticated to ${chalk.blue(config.auth0Domain)} using client credentials.\n`
);
} catch (error) {
logError('Client credentials authentication error:', error);
cliOutput(`\n${chalk.red('✗')} Failed to authenticate with client credentials.\n`);
process.exit(1);
}
}

/**
* Store token information from client credentials flow
*
* @param {any} tokenSet - Token response from the server
* @param {string} domain - The domain used for authentication
*/
async function storeTokenInfo(tokenSet: any, domain: string): Promise<void> {
// For client credentials flow, we use the provided domain directly,
// as the token may not contain tenant information in the same format as device flow

// Store access token
await keychain.setToken(tokenSet.access_token);
await keychain.setDomain(domain);

// Client credentials flow typically doesn't return refresh tokens
// but we'll handle it just in case
if (tokenSet.refresh_token) {
await keychain.setRefreshToken(tokenSet.refresh_token);
log('Refresh token stored in keychain');
}

// Set token expiration
if (tokenSet.expires_in) {
const expiresAt = Date.now() + tokenSet.expires_in * 1000;
await keychain.setTokenExpiresAt(expiresAt);
log(`Token expires at: ${new Date(expiresAt).toISOString()}`);
}
}
41 changes: 40 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { clients } from '../clients/index.js';
import type { ClientType } from '../clients/types.js';
import { log, logError } from '../utils/logger.js';
import { requestAuthorization } from '../auth/device-auth-flow.js';
import { requestClientCredentialsAuthorization } from '../auth/client-credentials-flow.js';
import { promptForScopeSelection } from '../utils/terminal.js';
import { getAllScopes } from '../utils/scopes.js';
import { Glob } from '../utils/glob.js';
Expand All @@ -17,6 +18,9 @@ export interface InitOptions {
scopes?: string[];
tools: string[];
readOnly?: boolean;
auth0Domain?: string;
auth0ClientId?: string;
auth0ClientSecret?: string;
}

/**
Expand Down Expand Up @@ -134,9 +138,44 @@ const init = async (options: InitOptions): Promise<void> => {

trackEvent.trackInit(options.client);

// Check if client credentials parameters are provided for Private Cloud authentication
const { auth0Domain, auth0ClientId, auth0ClientSecret } = options;
const hasClientCredentials = Boolean(auth0Domain && auth0ClientId && auth0ClientSecret);

// Check if client credentials are partially provided (which is invalid)
if (
(auth0Domain && (!auth0ClientId || !auth0ClientSecret)) ||
(auth0ClientId && (!auth0Domain || !auth0ClientSecret)) ||
(auth0ClientSecret && (!auth0Domain || !auth0ClientId))
) {
logError(
'Error: When using client credentials authentication, all three parameters are required:'
);
logError(
'--auth0-domain <auth0domain> --auth0-client-id <auth0-client-id> --auth0-client-secret <auth0-client-secret>'
);
process.exit(1);
return;
}

// Handle scope resolution
const selectedScopes = await resolveScopes(options.scopes);
await requestAuthorization(selectedScopes);

if (hasClientCredentials) {
// Client credentials flow for Private Cloud
log('Using client credentials flow for authentication');
await requestClientCredentialsAuthorization({
auth0Domain: auth0Domain as string,
auth0ClientId: auth0ClientId as string,
auth0ClientSecret: auth0ClientSecret as string,
scopes: selectedScopes,
});
} else {
// Device authorization flow for public cloud
log('Using device authorization flow for authentication');

await requestAuthorization(selectedScopes);
}

// Configure the requested client
await configureClient(options.client, options);
Expand Down
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Examples:
npx ${packageName} init --read-only --client claude
npx ${packageName} init --tools 'auth0_*_applications' --client windsurf
npx ${packageName} init --tools 'auth0_list_*,auth0_get_*' --client cursor
npx ${packageName} init --auth0-domain <auth0-domain> --auth0-client-id <auth0-client-id> --auth0-client-secret <auth0-client-secret>
npx ${packageName} run
npx ${packageName} run --read-only
npx ${packageName} session
Expand All @@ -81,6 +82,18 @@ program
.command('init')
.description('Initialize the server (authenticate and configure)')
.option('--client <client>', 'Configure specific client (claude, windsurf, or cursor)', 'claude')
.option(
'--auth0-domain <auth0 domain>',
'Auth0 domain (required for Private Cloud authentication)'
)
.option(
'--auth0-client-id <auth0 ClientId>',
'Client ID (required for Private Cloud authentication)'
)
.option(
'--auth0-client-secret <auth0 Client Secret>',
'Client secret (required for Private Cloud authentication)'
)
.option('--scopes <scopes>', 'Comma-separated list of Auth0 API scopes', (text) =>
text
.split(',')
Expand Down
167 changes: 167 additions & 0 deletions test/auth/client-credentials-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { requestClientCredentialsAuthorization } from '../../src/auth/client-credentials-flow';
import { keychain } from '../../src/utils/keychain';

// Mock dependencies
vi.mock('../../src/utils/logger', () => ({
log: vi.fn(),
logError: vi.fn(),
}));

vi.mock('../../src/utils/terminal', () => ({
cliOutput: vi.fn(),
getTenantFromToken: vi.fn().mockReturnValue('test-tenant'),
}));

vi.mock('../../src/utils/keychain', () => ({
keychain: {
setToken: vi.fn().mockResolvedValue(undefined),
setDomain: vi.fn().mockResolvedValue(undefined),
setRefreshToken: vi.fn().mockResolvedValue(undefined),
setTokenExpiresAt: vi.fn().mockResolvedValue(undefined),
},
}));

describe('client-credentials-flow', () => {
// Create a mock for global fetch
const mockFetch = vi.fn();
const mockProcess = {
exit: vi.fn(),
};

beforeEach(() => {
// Setup global fetch mock
global.fetch = mockFetch;
// Backup and replace process.exit
global.process.exit = mockProcess.exit as any;
});

afterEach(() => {
vi.resetAllMocks();
});

describe('requestClientCredentialsAuthorization', () => {
it('should successfully obtain and store tokens via client credentials flow', async () => {
// Setup mock response for the token request
const mockTokenResponse = {
access_token: 'test-access-token',
expires_in: 86400,
token_type: 'Bearer',
};

// Configure fetch mock to return token response
mockFetch.mockResolvedValueOnce({
json: async () => mockTokenResponse,
});

// Test config
const testConfig = {
auth0Domain: 'test-domain.auth0.com',
auth0ClientId: 'test-client-id',
auth0ClientSecret: 'test-client-secret',
scopes: ['read:users', 'read:clients'],
};

// Execute the function
await requestClientCredentialsAuthorization(testConfig);

// Verify fetch was called with the right parameters
expect(mockFetch).toHaveBeenCalledWith(
`https://${testConfig.auth0Domain}/oauth/token`,
expect.objectContaining({
method: 'POST',
body: expect.any(URLSearchParams),
headers: expect.objectContaining({
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
}),
})
);

// Verify the request body
const fetchCall = mockFetch.mock.calls[0];
const requestBody = fetchCall[1].body;
expect(requestBody.get('client_id')).toBe(testConfig.auth0ClientId);
expect(requestBody.get('client_secret')).toBe(testConfig.auth0ClientSecret);
expect(requestBody.get('grant_type')).toBe('client_credentials');
expect(requestBody.get('scope')).toBe(testConfig.scopes.join(' '));
expect(requestBody.get('audience')).toBe(`https://${testConfig.auth0Domain}/api/v2/`);

// Verify the tokens were stored correctly
expect(keychain.setToken).toHaveBeenCalledWith(mockTokenResponse.access_token);
expect(keychain.setDomain).toHaveBeenCalledWith(testConfig.auth0Domain);
expect(keychain.setTokenExpiresAt).toHaveBeenCalledWith(
expect.any(Number) // We don't need to test the exact timestamp
);
});

it('should handle authentication errors', async () => {
// Setup mock error response
const mockErrorResponse = {
error: 'invalid_client',
error_description: 'Client authentication failed',
};

// Configure fetch mock to return error
mockFetch.mockResolvedValueOnce({
json: async () => mockErrorResponse,
});

// Test config
const testConfig = {
auth0Domain: 'test-domain.auth0.com',
auth0ClientId: 'invalid-client-id',
auth0ClientSecret: 'invalid-client-secret',
};

// Execute the function and expect process.exit to be called
await requestClientCredentialsAuthorization(testConfig);
expect(mockProcess.exit).toHaveBeenCalledWith(1);
});

it('should handle fetch errors', async () => {
// Configure fetch mock to throw an error
mockFetch.mockRejectedValueOnce(new Error('Network error'));

// Test config
const testConfig = {
auth0Domain: 'test-domain.auth0.com',
auth0ClientId: 'test-client-id',
auth0ClientSecret: 'test-client-secret',
};

// Execute the function and expect process.exit to be called
await requestClientCredentialsAuthorization(testConfig);
expect(mockProcess.exit).toHaveBeenCalledWith(1);
});

it('should use default audience if none provided', async () => {
// Setup mock response for the token request
const mockTokenResponse = {
access_token: 'test-access-token',
expires_in: 86400,
token_type: 'Bearer',
};

// Configure fetch mock to return token response
mockFetch.mockResolvedValueOnce({
json: async () => mockTokenResponse,
});

// Test config without audience
const testConfig = {
auth0Domain: 'test-domain.auth0.com',
auth0ClientId: 'test-client-id',
auth0ClientSecret: 'test-client-secret',
};

// Execute the function
await requestClientCredentialsAuthorization(testConfig);

// Verify the request body contains default audience
const fetchCall = mockFetch.mock.calls[0];
const requestBody = fetchCall[1].body;
expect(requestBody.get('audience')).toBe(`https://${testConfig.auth0Domain}/api/v2/`);
});
});
});
Loading