Testing authentication is critical for MCP servers that require authorization. The auth fixture provides tools for creating test tokens, testing expiration, and validating scope enforcement.
Why Test Authentication?
Authentication tests verify that your server:
- Rejects requests without valid tokens
- Accepts requests with valid tokens
- Enforces token expiration
- Validates token signatures
- Respects scope-based permissions
Creating Test Tokens
The auth fixture generates real JWT tokens using RS256 signing:
test('basic token creation', async ({ mcp, auth }) => {
const token = await auth.createToken({
sub: 'user-123', // Subject (user ID)
scopes: ['read', 'write'], // OAuth scopes
email: 'user@example.com', // Email claim
name: 'John Doe', // Name claim
});
await mcp.authenticate(token);
// Now all requests include this token
const tools = await mcp.tools.list();
expect(tools.length).toBeGreaterThan(0);
});
Token Options
| Option | Type | Required | Description |
|---|
sub | string | Yes | Subject (user identifier) |
scopes | string[] | No | OAuth scopes |
email | string | No | Email claim |
name | string | No | Name claim |
claims | Record<string, unknown> | No | Additional custom claims |
expiresIn | number | No | Token lifetime in seconds (default: 3600) |
Custom Claims
Add any custom claims your server needs:
const token = await auth.createToken({
sub: 'user-123',
claims: {
tenantId: 'tenant-abc',
role: 'admin',
permissions: ['users:read', 'users:write'],
metadata: { region: 'us-east' },
},
});
Pre-built Test Users
The auth fixture includes pre-configured test users for common scenarios:
test('using pre-built users', async ({ mcp, auth }) => {
// Admin with full permissions
const adminToken = await auth.createToken(auth.users.admin);
// Regular user
const userToken = await auth.createToken(auth.users.user);
// Read-only user
const readOnlyToken = await auth.createToken(auth.users.readOnly);
});
User Definitions
| User | sub | Scopes |
|---|
admin | test-admin | ['*'] (all scopes) |
user | test-user | ['read', 'write'] |
readOnly | test-readonly | ['read'] |
Testing Token Expiration
Expired Tokens
test('rejects expired tokens', async ({ mcp, auth }) => {
const expiredToken = await auth.createExpiredToken({
sub: 'user-123',
});
await expect(mcp.authenticate(expiredToken))
.rejects.toThrow('expired');
});
Short-Lived Tokens
test('token expires after time limit', async ({ mcp, auth }) => {
// Token expires in 1 second
const token = await auth.createToken({
sub: 'user-123',
expiresIn: 1,
});
await mcp.authenticate(token);
// First request succeeds
await mcp.tools.list();
// Wait for expiration
await new Promise(resolve => setTimeout(resolve, 1500));
// Next request fails
await expect(mcp.tools.list())
.rejects.toThrow('expired');
});
Testing Invalid Tokens
Invalid Signature
test('rejects invalid signature', async ({ mcp, auth }) => {
// Create a token with an invalid signature
const invalidToken = auth.createInvalidToken({
sub: 'user-123',
});
await expect(mcp.authenticate(invalidToken))
.rejects.toThrow('invalid signature');
});
test('rejects malformed token', async ({ mcp }) => {
await expect(mcp.authenticate('not.a.valid.token'))
.rejects.toThrow();
await expect(mcp.authenticate(''))
.rejects.toThrow();
});
Testing Scope Enforcement
Scope-Based Access Control
test('enforces read-only scope', async ({ mcp, auth }) => {
// Create read-only token
const readOnlyToken = await auth.createToken({
sub: 'user-123',
scopes: ['read'],
});
await mcp.authenticate(readOnlyToken);
// Read operations succeed
const tools = await mcp.tools.list();
expect(tools.length).toBeGreaterThan(0);
// Write operations fail (assuming tool requires 'write' scope)
const result = await mcp.tools.call('create-note', { title: 'Test' });
expect(result).toBeError(); // Insufficient permissions
});
Testing Multiple Scopes
test('requires specific scopes', async ({ server, auth }) => {
// Create clients with different scopes
const readClient = await server.createClient({
token: await auth.createToken({ sub: 'user-1', scopes: ['read'] }),
});
const writeClient = await server.createClient({
token: await auth.createToken({ sub: 'user-2', scopes: ['write'] }),
});
const fullClient = await server.createClient({
token: await auth.createToken({ sub: 'user-3', scopes: ['read', 'write'] }),
});
// Test each client's access
expect(await readClient.resources.list()).toHaveLength(5);
const readResult = await readClient.tools.call('create-note', { title: 'Test' });
expect(readResult).toBeError();
const writeResult = await writeClient.tools.call('create-note', { title: 'Test' });
expect(writeResult).toBeSuccessful();
// Cleanup
await readClient.disconnect();
await writeClient.disconnect();
await fullClient.disconnect();
});
JWKS Integration
For servers that verify tokens against JWKS endpoints:
test('JWKS endpoint', async ({ auth }) => {
// Get the public JWKS
const jwks = await auth.getJwks();
expect(jwks.keys).toHaveLength(1);
expect(jwks.keys[0].kty).toBe('RSA');
expect(jwks.keys[0].use).toBe('sig');
});
test('issuer and audience', async ({ auth }) => {
const issuer = auth.getIssuer();
const audience = auth.getAudience();
expect(issuer).toContain('frontmcp-test');
expect(audience).toBe('mcp-server');
});
Testing Auth Modes
Public Mode (No Auth)
test.describe('Public Mode', () => {
test.use({
server: './src/main.ts',
auth: { mode: 'public' },
});
test('allows anonymous access', async ({ mcp }) => {
expect(mcp.auth.isAnonymous).toBe(true);
// All operations work without token
const tools = await mcp.tools.list();
expect(tools.length).toBeGreaterThan(0);
});
});
Orchestrated Mode (Auth Required)
test.describe('Orchestrated Mode', () => {
test.use({
server: './src/main.ts',
auth: { mode: 'orchestrated', type: 'local' },
});
test('requires authentication', async ({ mcp }) => {
// Without token, requests fail
await expect(mcp.tools.list())
.rejects.toThrow('Unauthorized');
});
test('works with valid token', async ({ mcp, auth }) => {
const token = await auth.createToken({ sub: 'user-123' });
await mcp.authenticate(token);
const tools = await mcp.tools.list();
expect(tools.length).toBeGreaterThan(0);
});
});
Real-World Examples
Testing User Isolation
test('users can only access their own data', async ({ server, auth }) => {
const user1Client = await server.createClient({
token: await auth.createToken({ sub: 'user-1' }),
});
const user2Client = await server.createClient({
token: await auth.createToken({ sub: 'user-2' }),
});
// User 1 creates a note
await user1Client.tools.call('create-note', {
title: 'Private Note',
});
// User 2 cannot see it
const user2Notes = await user2Client.resources.read('notes://all');
expect(user2Notes.json().notes).toHaveLength(0);
// User 1 can see it
const user1Notes = await user1Client.resources.read('notes://all');
expect(user1Notes.json().notes).toHaveLength(1);
await user1Client.disconnect();
await user2Client.disconnect();
});
Testing Admin Operations
test('admin can perform privileged operations', async ({ server, auth }) => {
const userClient = await server.createClient({
token: await auth.createToken(auth.users.user),
});
const adminClient = await server.createClient({
token: await auth.createToken(auth.users.admin),
});
// Regular user cannot delete all notes
const userResult = await userClient.tools.call('admin:clear-all', {});
expect(userResult).toBeError();
// Admin can delete all notes
const adminResult = await adminClient.tools.call('admin:clear-all', {});
expect(adminResult).toBeSuccessful();
await userClient.disconnect();
await adminClient.disconnect();
});
Best Practices
Do:
- Test both positive (valid token) and negative (invalid/expired) cases
- Test scope enforcement for all protected operations
- Use pre-built test users for common scenarios
- Clean up created clients after multi-user tests
Don’t:
- Hard-code tokens in tests (always use
auth.createToken())
- Skip expiration testing
- Assume scopes are enforced without testing
- Forget to test anonymous access for public endpoints
MockOAuthServer
For integration testing of OAuth flows, use MockOAuthServer from @frontmcp/testing. It provides a fully functional OAuth 2.1 server with PKCE support.
Basic Usage
import { MockOAuthServer, TestTokenFactory } from '@frontmcp/testing';
// Create a token factory (shares signing keys with server)
const tokenFactory = new TestTokenFactory();
const oauthServer = new MockOAuthServer(tokenFactory, {
autoApprove: true,
testUser: { sub: 'user-123', email: 'test@example.com' },
clientId: 'my-client',
validRedirectUris: ['http://localhost:3000/callback'],
});
// Start the server
const serverInfo = await oauthServer.start();
console.log(`OAuth server running at ${serverInfo.baseUrl}`);
// Use serverInfo.issuer for your MCP server configuration
// Stop when done
await oauthServer.stop();
MockOAuthServerOptions
| Option | Type | Default | Description |
|---|
port | number | Random | Port to listen on |
issuer | string | Auto | Issuer URL for tokens |
debug | boolean | false | Enable debug logging |
autoApprove | boolean | false | Auto-approve authorization requests |
testUser | MockTestUser | - | User to return on authorization |
clientId | string | - | Expected client ID for validation |
clientSecret | string | - | Client secret (for confidential clients) |
validRedirectUris | string[] | - | Allowed redirect URIs (supports wildcards) |
accessTokenTtlSeconds | number | 3600 | Access token lifetime |
refreshTokenTtlSeconds | number | 2592000 | Refresh token lifetime (30 days) |
MockTestUser Interface
interface MockTestUser {
sub: string; // Subject identifier (required)
email?: string; // User email
name?: string; // Display name
picture?: string; // Profile picture URL
claims?: Record<string, unknown>; // Additional token claims
}
MockOAuthServerInfo Interface
interface MockOAuthServerInfo {
baseUrl: string; // e.g., http://localhost:54321
port: number; // e.g., 54321
issuer: string; // e.g., http://localhost:54321
jwksUrl: string; // e.g., http://localhost:54321/.well-known/jwks.json
}
Available Endpoints
| Endpoint | Method | Description |
|---|
/.well-known/jwks.json | GET | Public signing keys |
/.well-known/openid-configuration | GET | OIDC discovery document |
/.well-known/oauth-authorization-server | GET | OAuth metadata |
/oauth/authorize | GET | Authorization endpoint |
/oauth/authorize/submit | POST | Authorization form submission |
/oauth/token | POST | Token endpoint |
/userinfo | GET | User info endpoint |
Supported Grant Types
authorization_code - Standard OAuth flow with PKCE
refresh_token - Refresh token rotation
anonymous - Issue anonymous tokens for testing
Example: Full OAuth Flow Test
import { test, expect } from '@playwright/test';
import { MockOAuthServer, TestTokenFactory } from '@frontmcp/testing';
test('complete OAuth authorization flow', async ({ page }) => {
const tokenFactory = new TestTokenFactory();
const oauthServer = new MockOAuthServer(tokenFactory, {
autoApprove: true,
testUser: {
sub: 'test-user-123',
email: 'test@example.com',
name: 'Test User',
},
clientId: 'test-client',
validRedirectUris: ['http://localhost:3000/*'],
});
const { baseUrl } = await oauthServer.start();
try {
// Configure your app to use this OAuth server
// Then navigate to your app's login
await page.goto('http://localhost:3000/login');
// With autoApprove=true, the server automatically redirects
// back with an authorization code
// Verify the callback was successful
await expect(page).toHaveURL(/callback.*code=/);
} finally {
await oauthServer.stop();
}
});
Configuration Methods
// Change auto-approve mode at runtime
oauthServer.setAutoApprove(true);
// Update test user
oauthServer.setTestUser({ sub: 'new-user', email: 'new@example.com' });
// Add valid redirect URI
oauthServer.addValidRedirectUri('http://localhost:4000/callback');
// Clear stored tokens (for test isolation)
oauthServer.clearStoredTokens();
// Access the token factory
const factory = oauthServer.getTokenFactory();
MockOAuthServer is intended for testing only. It accepts any valid-looking credentials in non-autoApprove mode. Never use it in production.