Tutorial: Adding Auth0 Integration (Modular)
This tutorial builds upon the Implementing Basic Governance (Identity & RBAC) guide. We will replace the simple testIdentityResolver
and InMemoryRoleStore
with modular, reusable implementations that integrate with Auth0 for real-world authentication and role management.
Prerequisites:
- Completion of the previous Basic Governance Tutorial.
- An existing Auth0 tenant and application configuration.
- An Auth0 API configured (this defines the
audience
for your tokens). - Familiarity with JWT concepts and Auth0 configuration basics.
To refactor the MCP server from the previous tutorial, extracting Auth0 logic into separate modules and configuring the GovernedServer
to use them. This promotes cleaner code and reusability.
This guide assumes clients obtain a valid Auth0 Access Token and send it in the Authorization: Bearer <token>
header. Testing requires a client/transport that supports headers.
Step 3: Add Auth0 Dependencies & Setup Review
We need libraries to verify Auth0’s JWTs and fetch their signing keys. We also need key information from your Auth0 setup.
Install Dependencies
If you haven’t already, in your my-governed-mcp-app
project directory:
npm install jsonwebtoken jwks-rsa
npm install --save-dev @types/jsonwebtoken
jsonwebtoken
: For verifying JWT signatures and decoding tokens.jwks-rsa
: To fetch public keys from Auth0’s JWKS endpoint dynamically.
Review Auth0 Setup
Ensure you have the following information from your Auth0 dashboard:
- Auth0 Domain: Your tenant domain (e.g.,
your-tenant.us.auth0.com
). - API Identifier (Audience): The unique identifier for your API (e.g.,
https://api.yourapp.com
).
If you plan to use Auth0 roles (Step 5):
- Enable RBAC and “Add Permissions in the Access Token” in your Auth0 API settings.
- Define roles and assign them to users.
- Note the claim name where roles/permissions appear in your Access Token (e.g., default
permissions
or customhttps://myapp.example.com/roles
).
Step 4: Create Modular Auth0 Identity Resolver
We extract the JWT validation logic into its own file, making it reusable and keeping the main server file cleaner. Configuration is passed via the constructor.
Create src/auth/auth0-identity-resolver.ts
Create a new directory src/auth
and place the following code inside auth0-identity-resolver.ts
.
import {
IdentityResolver, OperationContext, UserIdentity, AuthenticationError, Logger // Import Logger
} from '@ithena-one/mcp-governance'; // Adjust path if needed
import jwt from 'jsonwebtoken';
import jwksClient, { JwksClient } from 'jwks-rsa';
// Interface for constructor options
interface Auth0ResolverConfig {
auth0Domain: string;
apiAudience: string;
logger?: Logger; // Optional logger instance
}
export class Auth0IdentityResolver implements IdentityResolver {
private readonly config: Auth0ResolverConfig;
private readonly jwksRsaClient: JwksClient;
private readonly logger: Logger; // Internal logger instance
// Receive configuration via constructor
constructor(config: Auth0ResolverConfig) {
if (!config.auth0Domain || !config.apiAudience) {
throw new Error('Auth0 domain and API audience must be provided.');
}
this.config = config;
// Use provided logger or create a minimal fallback
this.logger = config.logger || console; // Use console as basic fallback
this.jwksRsaClient = jwksClient({
jwksUri: `https://${this.config.auth0Domain}/.well-known/jwks.json`,
cache: true, rateLimit: true,
});
}
// Helper to get the signing key
private getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void {
const log = this.logger; // Use instance logger
if (!header.kid) {
log.warn?.('Token KID missing'); // Use optional chaining for fallback logger
return callback(new Error('Token KID missing'));
}
this.jwksRsaClient.getSigningKey(header.kid, (err, key) => {
if (err) {
log.error?.('Error fetching signing key from JWKS', err); // Log error
return callback(err);
}
const signingKey = key?.getPublicKey();
if (!signingKey) {
log.warn?.('Signing key not found for KID', { kid: header.kid });
return callback(new Error('Signing key not found'));
}
callback(null, signingKey);
});
}
async resolveIdentity(opCtx: OperationContext): Promise<UserIdentity | null> {
// Use the request-scoped logger if available, otherwise use the instance logger
const scopedLogger = opCtx.logger || this.logger;
const authHeader = opCtx.transportContext.headers?.['authorization'] || opCtx.transportContext.headers?.['Authorization'];
const token = Array.isArray(authHeader) ? authHeader[0]?.split(' ')[1] : authHeader?.split(' ')[1];
if (!token) {
scopedLogger.debug?.('No Bearer token found');
return null; // Allow anonymous
}
try {
const decoded = await new Promise<jwt.JwtPayload | undefined>((resolve, reject) => {
jwt.verify(token, this.getKey.bind(this), { // Bind 'this' for getKey
audience: this.config.apiAudience,
issuer: `https://${this.config.auth0Domain}/`,
algorithms: ['RS256'],
}, (err, decodedPayload) => {
if (err) { return reject(err); }
resolve(decodedPayload as jwt.JwtPayload | undefined);
});
});
if (!decoded || !decoded.sub) { throw new Error('Token invalid/missing sub'); }
scopedLogger.info?.(`Auth0 token validated: ${decoded.sub}`);
return { id: decoded.sub, claims: decoded, source: 'auth0' };
} catch (error: any) {
scopedLogger.error?.('Auth0 token validation failed', { error: error.message, name: error.name });
throw new AuthenticationError(`Auth0 validation failed: ${error.message}`, { originalError: error.name });
}
}
}
This module now takes the Auth0 domain and audience as constructor arguments, making it more reusable and testable. It also includes basic logging using the provided logger or a console fallback.
Prepare governed-app.ts
for Import
In src/governed-app.ts
:
- Remove/Comment Out: The old
testIdentityResolver
definition. - Remove/Comment Out: The hardcoded
AUTH0_DOMAIN
,API_AUDIENCE
constants and thejwksClient
/getKey
logic if you added them directly in the previous version. The logic now lives in the new module.
Step 5: Create Modular Auth0 Role Store (Optional)
Similarly, we extract the role extraction logic into its own file. Configuration (the roles claim name) is passed via the constructor.
Create src/auth/auth0-role-store.ts
import {
RoleStore, UserIdentity, OperationContext, Logger // Import Logger
} from '@ithena-one/mcp-governance'; // Adjust path if needed
// Interface for constructor options
interface Auth0RoleStoreConfig {
rolesClaim: string;
logger?: Logger; // Optional logger
}
export class Auth0RoleStore implements RoleStore {
private readonly config: Auth0RoleStoreConfig;
private readonly logger: Logger;
constructor(config: Auth0RoleStoreConfig) {
if (!config.rolesClaim) {
throw new Error('Auth0 roles claim name must be provided.');
}
this.config = config;
this.logger = config.logger || console; // Use console fallback
}
async getRoles(identity: UserIdentity, opCtx: OperationContext): Promise<string[]> {
const scopedLogger = opCtx.logger || this.logger; // Use request or instance logger
if (typeof identity === 'object' && identity !== null && identity.source === 'auth0' && identity.claims) {
const claims = identity.claims as Record<string, any>;
const roles = claims[this.config.rolesClaim]; // Use configured claim name
if (Array.isArray(roles) && roles.every(r => typeof r === 'string')) {
scopedLogger.debug?.(`Extracted Auth0 roles from claim '${this.config.rolesClaim}'`, { roles });
return roles;
} else {
scopedLogger.debug?.(`Roles claim '${this.config.rolesClaim}' not found or not a string array`, { claims: Object.keys(claims) });
}
} else {
scopedLogger.debug?.('Cannot extract Auth0 roles: Invalid identity object');
}
return [];
}
}
This module takes the specific Auth0 claim name containing roles as a constructor argument.
Prepare governed-app.ts
for Import
In src/governed-app.ts
:
- Remove/Comment Out: The old
testRoleStore
definition (InMemoryRoleStore
). - Remove/Comment Out: The hardcoded
ROLES_CLAIM
constant if you added it directly before.
Step 6: Update governed-app.ts
to Use Modules
Now we import the decoupled Auth0 components and instantiate them with the required configuration before passing them to GovernedServer
.
Modify src/governed-app.ts
:
// src/governed-app.ts
import { Server as BaseServer } from '@modelcontextprotocol/sdk/server/index.js';
// Use a transport supporting headers for testing, like HttpTransport or SseTransport eventually
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import process from 'node:process';
// --- Import Governance SDK & Your Auth0 Modules ---
import { /* ... other governance imports ... */ } from '@ithena-one/mcp-governance';
import { Auth0IdentityResolver } from './auth/auth0-identity-resolver.js'; // <-- IMPORT
import { Auth0RoleStore } from './auth/auth0-role-store.js'; // <-- IMPORT (Optional)
console.log('Starting Governed MCP Server (Modular Auth0)...');
// --- 1. Base Server & Default Components ---
const baseServer = new BaseServer({ name: "MyGovernedMCPServer-ModularAuth0", version: "1.0.0" }, { capabilities: { tools: {} } });
const logger = new ConsoleLogger({}, 'debug');
const auditStore = new ConsoleAuditLogStore();
const testPermissionStore = new InMemoryPermissionStore({ /* ... permissions ... */ }); // Keep test permissions for now
// --- 2. Configure and Instantiate Auth0 Components ---
// !! REPLACE with your actual values (or load from environment variables) !!
const AUTH0_DOMAIN = 'YOUR_AUTH0_DOMAIN';
const API_AUDIENCE = 'YOUR_API_AUDIENCE';
const AUTH0_ROLES_CLAIM = 'https://myapp.example.com/roles'; // Replace if needed
const identityResolver = new Auth0IdentityResolver({
auth0Domain: AUTH0_DOMAIN,
apiAudience: API_AUDIENCE,
logger: logger // Pass the main logger instance
});
const roleStore = new Auth0RoleStore({ // Optional: Only if using Auth0 roles
rolesClaim: AUTH0_ROLES_CLAIM,
logger: logger // Pass the main logger instance
});
// --- 3. GovernedServer Configuration (Using Auth0 Components) ---
const governedServerOptions: GovernedServerOptions = {
logger: logger,
auditStore: auditStore,
identityResolver: identityResolver, // <-- Use instance
roleStore: roleStore, // <-- Use instance (Optional)
permissionStore: testPermissionStore, // Keep test permissions
enableRbac: true, // RBAC ON
auditDeniedRequests: true,
serviceIdentifier: "governed-app-auth0-modular",
// ... other options if needed
};
// --- 4. Create GovernedServer instance ---
const governedServer = new GovernedServer(baseServer, governedServerOptions);
logger.info('GovernedServer created with modular Auth0 components');
// --- 5. Define Tool Schemas (testUserId removed) ---
const helloToolSchema = z.object({ /* ... schema from previous step ... */ jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]), method: z.literal('tools/callHello'), params: z.object({ arguments: z.object({ greeting: z.string().optional().default('Hello') }).optional().default({ greeting: 'Hello' }), _meta: z.any().optional() }) }); // Added back
const sensitiveToolSchema = z.object({ /* ... schema from previous step ... */ jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]), method: z.literal('tools/callSensitive'), params: z.object({ arguments: z.any().optional(), _meta: z.any().optional() }) }); // Added back
// --- 6. Register Handlers (testUserId removed) ---
governedServer.setRequestHandler(helloToolSchema,
async (request, extra: GovernedRequestHandlerExtra) => { /* ... handler logic from previous step ... */ const scopedLogger = extra.logger || logger; const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id; scopedLogger.info(`[Handler] Executing callHello for identity: ${identityId || 'anonymous'} with roles: ${JSON.stringify(extra.roles)}. EventID: ${extra.eventId}`); const greeting = request.params?.arguments?.greeting || 'DefaultGreeting'; const responseText = `${greeting} ${identityId || 'World'} from governed server!`; return { content: [{ type: 'text', text: responseText }] }; }); // Added back
governedServer.setRequestHandler(sensitiveToolSchema,
async (request, extra: GovernedRequestHandlerExtra) => { /* ... handler logic from previous step ... */ const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id; const scopedLogger = extra.logger || logger; scopedLogger.info(`[Handler] Executing callSensitive for identity: ${identityId}`, { roles: extra.roles }); return { content: [{ type: 'text', text: `Sensitive data accessed by ${identityId}` }] }; }); // Added back
logger.info('Handlers registered.');
// --- 7. Connect and Shutdown ---
const transport = new StdioServerTransport(); // Needs header support for real testing
async function startServer() { try { await governedServer.connect(transport); logger.info("Governed MCP server (Modular Auth0) started."); } catch (error) { logger.error("Failed to start server", error as Error); process.exit(1); } }
const shutdown = async () => { /* ... implementation ... */ logger.info("Shutting down..."); try { await governedServer.close(); logger.info("Shutdown complete."); process.exit(0); } catch (err) { logger.error("Error during shutdown:", err); process.exit(1); } }; // Added back
process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);
startServer();
In a real application, load AUTH0_DOMAIN
, API_AUDIENCE
, and AUTH0_ROLES_CLAIM
from environment variables (process.env.AUTH0_DOMAIN
, etc.) instead of hardcoding them in governed-app.ts
.
Step 7: Testing (Same as Before)
The testing procedure remains the same as in Step 4 and Step 5 of the previous guide version. You still need to:
- Rebuild:
npm run build
- Run:
npm run start
- Obtain Token: Get a valid Auth0 Access Token.
- Use
curl
(or similar client with header support): Send requests with theAuthorization: Bearer <token>
header.
The behavior should be identical to the previous version. Requests with valid tokens are identified, roles (if configured) are extracted from the token claim, and RBAC rules (using testPermissionStore
) are applied. The key difference is that the Auth0 logic is now neatly encapsulated in separate modules.
Final Code Structure
Your src
directory should now look something like this:
└── src/
├── auth/
│ ├── auth0-identity-resolver.ts
│ └── auth0-role-store.ts (Optional)
└── governed-app.ts (Imports from ./auth)
Next Steps & Production Considerations
You’ve successfully refactored your Auth0 integration into reusable modules! The production considerations remain the same:
- Replace
InMemoryPermissionStore
. - Use a production transport (HTTP/SSE/WebSocket) with TLS.
- Load configuration securely from environment variables.
- Enhance error handling and monitoring.
- Review audit sanitization (
sanitizeForAudit
). - Replace console logger/auditor.
- Keep dependencies updated.
This modular approach makes your codebase cleaner, easier to test, and allows you to potentially swap out authentication providers more easily in the future.