import { DynamicModule, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { McpAuthJwtGuard } from './guards/jwt-auth.guard';
import { createMcpOAuthController } from './mcp-oauth.controller';
import type {
OAuthUserModuleOptions as AuthUserModuleOptions,
OAuthEndpointConfiguration,
OAuthModuleDefaults,
OAuthModuleOptions,
} from './providers/oauth-provider.interface';
import { ClientService } from './services/client.service';
import { JwtTokenService } from './services/jwt-token.service';
import { OAuthStrategyService } from './services/oauth-strategy.service';
import { MemoryStore } from './stores/memory-store.service';
import { normalizeEndpoint } from '../mcp/utils/normalize-endpoint';
import { OAUTH_TYPEORM_CONNECTION_NAME } from './stores/typeorm/constants';
let authInstanceIdCounter = 0;
// Default configuration values
export const DEFAULT_OPTIONS: OAuthModuleDefaults = {
serverUrl: 'http://localhost:3000',
resource: 'http://localhost:3000/mcp',
jwtIssuer: 'http://localhost:3000',
jwtAudience: 'mcp-client',
jwtAccessTokenExpiresIn: '1d',
jwtRefreshTokenExpiresIn: '30d',
enableRefreshTokens: true,
cookieMaxAge: 24 * 60 * 60 * 1000, // 24 hours
oauthSessionExpiresIn: 10 * 60 * 1000, // 10 minutes
authCodeExpiresIn: 10 * 60 * 1000, // 10 minutes
nodeEnv: 'development',
apiPrefix: '',
endpoints: {
wellKnownAuthorizationServerMetadata:
'/.well-known/oauth-authorization-server',
wellKnownProtectedResourceMetadata: '/.well-known/oauth-protected-resource',
register: '/register',
authorize: '/authorize',
callback: '/callback',
token: '/token',
revoke: '/revoke',
},
disableEndpoints: {
wellKnownAuthorizationServerMetadata: false,
wellKnownProtectedResourceMetadata: false,
},
protectedResourceMetadata: {
scopesSupported: ['offline_access'],
bearerMethodsSupported: ['header'],
mcpVersionsSupported: ['2025-06-18'],
},
authorizationServerMetadata: {
responseTypesSupported: ['code'],
responseModesSupported: ['query'],
grantTypesSupported: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethodsSupported: [
'client_secret_basic',
'client_secret_post',
'none',
],
scopesSupported: ['offline_access'],
codeChallengeMethodsSupported: ['plain', 'S256'],
},
};
@Module({})
export class McpAuthModule {
/**
* To avoid import circular dependency issues, we use a marker property.
*/
readonly __isMcpAuthModule = true;
static forRoot(options: AuthUserModuleOptions): DynamicModule {
// Create a unique instance ID for this auth module
const authModuleId = `mcp-auth-module-${authInstanceIdCounter++}`;
// Merge user options with defaults and validate
const resolvedOptions = this.mergeAndValidateOptions(
DEFAULT_OPTIONS,
options,
);
resolvedOptions.endpoints = prepareEndpoints(
resolvedOptions.apiPrefix,
DEFAULT_OPTIONS.endpoints,
options.endpoints || {},
);
// Use instance-scoped token for OAuth options
const oauthModuleOptionsToken = `OAUTH_MODULE_OPTIONS_${authModuleId}`;
const oauthModuleOptions = {
provide: oauthModuleOptionsToken,
useValue: resolvedOptions,
};
// Determine imports based on configuration
const imports = [
ConfigModule,
PassportModule.register({
defaultStrategy: 'jwt',
session: false,
}),
JwtModule.register({
secret: resolvedOptions.jwtSecret,
signOptions: {
issuer: resolvedOptions.jwtIssuer,
audience: resolvedOptions.jwtAudience,
},
}),
];
// Add TypeORM configuration if using TypeORM store
const storeConfig = resolvedOptions.storeConfiguration;
const isTypeOrmStore = storeConfig?.type === 'typeorm';
if (isTypeOrmStore) {
const typeormOptions = storeConfig.options;
try {
// Require TypeORM-related modules only when needed
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { TypeOrmModule } = require('@nestjs/typeorm');
const {
OAuthClientEntity,
AuthorizationCodeEntity,
OAuthSessionEntity,
OAuthUserProfileEntity,
// eslint-disable-next-line @typescript-eslint/no-require-imports
} = require('./stores/typeorm/entities');
imports.push(
TypeOrmModule.forRoot({
...typeormOptions,
// Use a unique connection name for the OAuth store to avoid clashes
name: OAUTH_TYPEORM_CONNECTION_NAME,
entities: [
OAuthClientEntity,
AuthorizationCodeEntity,
OAuthSessionEntity,
OAuthUserProfileEntity,
],
}),
TypeOrmModule.forFeature(
[
OAuthClientEntity,
AuthorizationCodeEntity,
OAuthSessionEntity,
OAuthUserProfileEntity,
],
OAUTH_TYPEORM_CONNECTION_NAME,
),
);
} catch (err) {
throw new Error(
"To use the TypeORM store, please install '@nestjs/typeorm' and 'typeorm'.",
);
}
}
// Create store provider based on configuration with instance-scoped token
const oauthStoreToken = `IOAuthStore_${authModuleId}`;
const oauthStoreProvider = this.createStoreProvider(
resolvedOptions.storeConfiguration,
oauthStoreToken,
);
// Create alias for compatibility with injection
const oauthStoreAliasProvider = {
provide: MemoryStore,
useExisting: oauthStoreToken,
};
const providers: any[] = [
{
provide: 'OAUTH_MODULE_ID',
useValue: authModuleId,
},
oauthModuleOptions,
oauthStoreProvider,
oauthStoreAliasProvider,
// Provide backward-compatible tokens as aliases
{
provide: 'OAUTH_MODULE_OPTIONS',
useExisting: oauthModuleOptionsToken,
},
{
provide: 'IOAuthStore',
useExisting: oauthStoreToken,
},
// Provide services using their class tokens
OAuthStrategyService,
ClientService,
JwtTokenService,
McpAuthJwtGuard,
];
// No additional providers needed for TypeORM store - provider is created dynamically
// Create controller with apiPrefix, passing the instance-scoped tokens
const OAuthControllerClass = createMcpOAuthController(
resolvedOptions.endpoints,
{
disableWellKnownAuthorizationServerMetadata:
resolvedOptions.disableEndpoints
.wellKnownAuthorizationServerMetadata ?? false,
disableWellKnownProtectedResourceMetadata:
resolvedOptions.disableEndpoints.wellKnownProtectedResourceMetadata ??
false,
},
authModuleId,
);
return {
module: McpAuthModule,
imports,
controllers: [OAuthControllerClass],
providers,
exports: [
'OAUTH_MODULE_ID',
'OAUTH_MODULE_OPTIONS',
'IOAuthStore',
JwtTokenService,
ClientService,
OAuthStrategyService,
McpAuthJwtGuard,
MemoryStore,
],
};
}
private static mergeAndValidateOptions(
defaults: OAuthModuleDefaults,
options: AuthUserModuleOptions,
): OAuthModuleOptions {
// Validate required options first
this.validateRequiredOptions(options);
// Merge with defaults
const resolvedOptions: OAuthModuleOptions = {
...defaults,
...options,
// Ensure jwtIssuer defaults to serverUrl if not provided
jwtIssuer:
options.jwtIssuer || options.serverUrl || DEFAULT_OPTIONS.jwtIssuer,
cookieSecure:
options.cookieSecure || process.env.NODE_ENV === 'production',
// Merge protectedResourceMetadata with defaults
protectedResourceMetadata: {
...defaults.protectedResourceMetadata,
...options.protectedResourceMetadata,
},
// Merge authorizationServerMetadata with defaults
authorizationServerMetadata: {
...defaults.authorizationServerMetadata,
...options.authorizationServerMetadata,
},
// Merge disableEndpoints with defaults
disableEndpoints: {
...defaults.disableEndpoints,
...(options.disableEndpoints || {}),
},
};
if (!resolvedOptions.enableRefreshTokens) {
resolvedOptions.authorizationServerMetadata.grantTypesSupported =
resolvedOptions.authorizationServerMetadata.grantTypesSupported.filter(
(g) => g !== 'refresh_token',
);
resolvedOptions.protectedResourceMetadata.scopesSupported =
resolvedOptions.protectedResourceMetadata.scopesSupported.filter(
(s) => s !== 'offline_access',
);
}
// Final validation of resolved options
this.validateResolvedOptions(resolvedOptions);
return resolvedOptions;
}
private static validateRequiredOptions(options: AuthUserModuleOptions): void {
const requiredFields: (keyof AuthUserModuleOptions)[] = [
'provider',
'clientId',
'clientSecret',
'jwtSecret',
];
for (const field of requiredFields) {
if (!options[field]) {
throw new Error(
`OAuthModuleOptions: ${String(field)} is required and must be provided by the user`,
);
}
}
}
private static validateResolvedOptions(options: OAuthModuleOptions): void {
// Validate JWT secret is strong enough
if (options.jwtSecret.length < 32) {
throw new Error(
'OAuthModuleOptions: jwtSecret must be at least 32 characters long',
);
}
// Validate URLs are proper format
try {
new URL(options.serverUrl);
new URL(options.jwtIssuer);
} catch {
throw new Error(
'OAuthModuleOptions: serverUrl and jwtIssuer must be valid URLs',
);
}
// Validate provider configuration
if (!options.provider.name || !options.provider.strategy) {
throw new Error(
'OAuthModuleOptions: provider must have name and strategy',
);
}
}
private static createStoreProvider(
storeConfiguration: OAuthModuleOptions['storeConfiguration'],
provideToken: string,
) {
if (!storeConfiguration || storeConfiguration.type === 'memory') {
// Default memory store
return {
provide: provideToken,
useValue: new MemoryStore(),
};
}
if (storeConfiguration.type === 'typeorm') {
// TypeORM store
const {
TypeOrmStore,
// eslint-disable-next-line @typescript-eslint/no-require-imports
} = require('./stores/typeorm/typeorm-store.service');
return {
provide: provideToken,
useClass: TypeOrmStore,
};
}
if (storeConfiguration.type === 'custom') {
// Custom store
return {
provide: provideToken,
useValue: storeConfiguration.store,
};
}
throw new Error(
`Unknown store configuration type: ${(storeConfiguration as any).type}`,
);
}
}
function prepareEndpoints(
apiPrefix: string,
defaultEndpoints: OAuthEndpointConfiguration,
configuredEndpoints: OAuthEndpointConfiguration,
) {
const updatedDefaultEndpoints = {
wellKnownAuthorizationServerMetadata:
defaultEndpoints.wellKnownAuthorizationServerMetadata,
wellKnownProtectedResourceMetadata:
defaultEndpoints.wellKnownProtectedResourceMetadata,
callback: normalizeEndpoint(`/${apiPrefix}/${defaultEndpoints.callback}`),
token: normalizeEndpoint(`/${apiPrefix}/${defaultEndpoints.token}`),
revoke: normalizeEndpoint(`/${apiPrefix}/${defaultEndpoints.revoke}`),
authorize: normalizeEndpoint(`/${apiPrefix}/${defaultEndpoints.authorize}`),
register: normalizeEndpoint(`/${apiPrefix}/${defaultEndpoints.register}`),
} as OAuthEndpointConfiguration;
return {
...updatedDefaultEndpoints,
...configuredEndpoints,
};
}