Files
mcphub/src/controllers/oauthDynamicRegistrationController.ts

544 lines
16 KiB
TypeScript

import { Request, Response } from 'express';
import crypto from 'crypto';
import {
createOAuthClient,
findOAuthClientById,
updateOAuthClient,
deleteOAuthClient,
} from '../models/OAuth.js';
import { IOAuthClient } from '../types/index.js';
import { loadSettings } from '../config/index.js';
// Store registration access tokens (in production, use database)
const registrationTokens = new Map<string, { clientId: string; createdAt: Date }>();
/**
* Generate registration access token
*/
const generateRegistrationToken = (clientId: string): string => {
const token = crypto.randomBytes(32).toString('hex');
registrationTokens.set(token, {
clientId,
createdAt: new Date(),
});
return token;
};
/**
* Verify registration access token
*/
const verifyRegistrationToken = (token: string): string | null => {
const data = registrationTokens.get(token);
if (!data) {
return null;
}
// Token expires after 30 days
const expiresAt = new Date(data.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000);
if (new Date() > expiresAt) {
registrationTokens.delete(token);
return null;
}
return data.clientId;
};
/**
* POST /oauth/register
* RFC 7591 Dynamic Client Registration
* Public endpoint for registering new OAuth clients
*/
export const registerClient = async (req: Request, res: Response): Promise<void> => {
try {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
// Check if dynamic registration is enabled
if (!oauthConfig?.dynamicRegistration?.enabled) {
res.status(403).json({
error: 'invalid_request',
error_description: 'Dynamic client registration is not enabled',
});
return;
}
// Validate required fields
const {
redirect_uris,
client_name,
grant_types,
response_types,
scope,
token_endpoint_auth_method,
application_type,
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
jwks_uri,
jwks,
} = req.body;
// redirect_uris is required
if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: 'redirect_uris is required and must be a non-empty array',
});
return;
}
// Validate redirect URIs
for (const uri of redirect_uris) {
try {
const url = new URL(uri);
// For security, only allow https (except localhost for development)
if (
url.protocol !== 'https:' &&
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Redirect URI must use HTTPS: ${uri}`,
});
return;
}
} catch (e) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Invalid redirect URI: ${uri}`,
});
return;
}
}
// Generate client credentials
const clientId = crypto.randomBytes(16).toString('hex');
// Determine if client secret is needed based on token_endpoint_auth_method
const authMethod = token_endpoint_auth_method || 'client_secret_basic';
const needsSecret = authMethod !== 'none';
const clientSecret = needsSecret ? crypto.randomBytes(32).toString('hex') : undefined;
// Default grant types
const defaultGrantTypes = ['authorization_code', 'refresh_token'];
const clientGrantTypes = grant_types || defaultGrantTypes;
// Validate grant types
const allowedGrantTypes = oauthConfig.dynamicRegistration.allowedGrantTypes || [
'authorization_code',
'refresh_token',
];
for (const grantType of clientGrantTypes) {
if (!allowedGrantTypes.includes(grantType)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Grant type not allowed: ${grantType}`,
});
return;
}
}
// Validate scopes
const requestedScopes = scope ? scope.split(' ') : ['read', 'write'];
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
for (const requestedScope of requestedScopes) {
if (!allowedScopes.includes(requestedScope)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Scope not allowed: ${requestedScope}`,
});
return;
}
}
// Generate registration access token
const registrationAccessToken = generateRegistrationToken(clientId);
const baseUrl =
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
const registrationClientUri = `${baseUrl}/oauth/register/${clientId}`;
// Create OAuth client
const client: IOAuthClient = {
clientId,
clientSecret,
name: client_name || 'Dynamically Registered Client',
redirectUris: redirect_uris,
grants: clientGrantTypes,
scopes: requestedScopes,
owner: 'dynamic-registration',
// Store additional metadata
metadata: {
application_type: application_type || 'web',
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
jwks_uri,
jwks,
token_endpoint_auth_method: authMethod,
response_types: response_types || ['code'],
},
};
const createdClient = await createOAuthClient(client);
// Build response according to RFC 7591
const response: any = {
client_id: createdClient.clientId,
client_name: createdClient.name,
redirect_uris: createdClient.redirectUris,
grant_types: createdClient.grants,
response_types: client.metadata?.response_types || ['code'],
scope: (createdClient.scopes || []).join(' '),
token_endpoint_auth_method: authMethod,
registration_access_token: registrationAccessToken,
registration_client_uri: registrationClientUri,
client_id_issued_at: Math.floor(Date.now() / 1000),
};
// Include client secret if generated
if (clientSecret) {
response.client_secret = clientSecret;
response.client_secret_expires_at = 0; // 0 means it doesn't expire
}
// Include optional metadata
if (application_type) response.application_type = application_type;
if (contacts) response.contacts = contacts;
if (logo_uri) response.logo_uri = logo_uri;
if (client_uri) response.client_uri = client_uri;
if (policy_uri) response.policy_uri = policy_uri;
if (tos_uri) response.tos_uri = tos_uri;
if (jwks_uri) response.jwks_uri = jwks_uri;
if (jwks) response.jwks = jwks;
res.status(201).json(response);
} catch (error) {
console.error('Dynamic client registration error:', error);
if (error instanceof Error && error.message.includes('already exists')) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: 'Client with this ID already exists',
});
} else {
res.status(500).json({
error: 'server_error',
error_description: 'Failed to register client',
});
}
}
};
/**
* GET /oauth/register/:clientId
* RFC 7591 Client Configuration Endpoint
* Read client configuration
*/
export const getClientConfiguration = async (req: Request, res: Response): Promise<void> => {
try {
const { clientId } = req.params;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Registration access token required',
});
return;
}
const token = authHeader.substring(7);
const tokenClientId = verifyRegistrationToken(token);
if (!tokenClientId || tokenClientId !== clientId) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired registration access token',
});
return;
}
const client = await findOAuthClientById(clientId);
if (!client) {
res.status(404).json({
error: 'invalid_client',
error_description: 'Client not found',
});
return;
}
// Build response
const response: any = {
client_id: client.clientId,
client_name: client.name,
redirect_uris: client.redirectUris,
grant_types: client.grants,
response_types: client.metadata?.response_types || ['code'],
scope: (client.scopes || []).join(' '),
token_endpoint_auth_method:
client.metadata?.token_endpoint_auth_method || 'client_secret_basic',
};
// Include optional metadata
if (client.metadata) {
if (client.metadata.application_type)
response.application_type = client.metadata.application_type;
if (client.metadata.contacts) response.contacts = client.metadata.contacts;
if (client.metadata.logo_uri) response.logo_uri = client.metadata.logo_uri;
if (client.metadata.client_uri) response.client_uri = client.metadata.client_uri;
if (client.metadata.policy_uri) response.policy_uri = client.metadata.policy_uri;
if (client.metadata.tos_uri) response.tos_uri = client.metadata.tos_uri;
if (client.metadata.jwks_uri) response.jwks_uri = client.metadata.jwks_uri;
if (client.metadata.jwks) response.jwks = client.metadata.jwks;
}
res.json(response);
} catch (error) {
console.error('Get client configuration error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Failed to retrieve client configuration',
});
}
};
/**
* PUT /oauth/register/:clientId
* RFC 7591 Client Update Endpoint
* Update client configuration
*/
export const updateClientConfiguration = async (req: Request, res: Response): Promise<void> => {
try {
const { clientId } = req.params;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Registration access token required',
});
return;
}
const token = authHeader.substring(7);
const tokenClientId = verifyRegistrationToken(token);
if (!tokenClientId || tokenClientId !== clientId) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired registration access token',
});
return;
}
const client = await findOAuthClientById(clientId);
if (!client) {
res.status(404).json({
error: 'invalid_client',
error_description: 'Client not found',
});
return;
}
const {
redirect_uris,
client_name,
grant_types,
scope,
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
} = req.body;
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
// Validate redirect URIs if provided
if (redirect_uris) {
if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: 'redirect_uris must be a non-empty array',
});
return;
}
for (const uri of redirect_uris) {
try {
const url = new URL(uri);
if (
url.protocol !== 'https:' &&
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Redirect URI must use HTTPS: ${uri}`,
});
return;
}
} catch (e) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Invalid redirect URI: ${uri}`,
});
return;
}
}
}
// Validate grant types if provided
if (grant_types) {
const allowedGrantTypes = oauthConfig?.dynamicRegistration?.allowedGrantTypes || [
'authorization_code',
'refresh_token',
];
for (const grantType of grant_types) {
if (!allowedGrantTypes.includes(grantType)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Grant type not allowed: ${grantType}`,
});
return;
}
}
}
// Validate scopes if provided
if (scope) {
const requestedScopes = scope.split(' ');
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
for (const requestedScope of requestedScopes) {
if (!allowedScopes.includes(requestedScope)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Scope not allowed: ${requestedScope}`,
});
return;
}
}
}
// Build updates
const updates: Partial<IOAuthClient> = {};
if (client_name) updates.name = client_name;
if (redirect_uris) updates.redirectUris = redirect_uris;
if (grant_types) updates.grants = grant_types;
if (scope) updates.scopes = scope.split(' ');
// Update metadata
if (client.metadata || contacts || logo_uri || client_uri || policy_uri || tos_uri) {
updates.metadata = {
...client.metadata,
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
};
}
const updatedClient = await updateOAuthClient(clientId, updates);
if (!updatedClient) {
res.status(500).json({
error: 'server_error',
error_description: 'Failed to update client',
});
return;
}
// Build response
const response: any = {
client_id: updatedClient.clientId,
client_name: updatedClient.name,
redirect_uris: updatedClient.redirectUris,
grant_types: updatedClient.grants,
response_types: updatedClient.metadata?.response_types || ['code'],
scope: (updatedClient.scopes || []).join(' '),
token_endpoint_auth_method:
updatedClient.metadata?.token_endpoint_auth_method || 'client_secret_basic',
};
// Include optional metadata
if (updatedClient.metadata) {
if (updatedClient.metadata.application_type)
response.application_type = updatedClient.metadata.application_type;
if (updatedClient.metadata.contacts) response.contacts = updatedClient.metadata.contacts;
if (updatedClient.metadata.logo_uri) response.logo_uri = updatedClient.metadata.logo_uri;
if (updatedClient.metadata.client_uri)
response.client_uri = updatedClient.metadata.client_uri;
if (updatedClient.metadata.policy_uri)
response.policy_uri = updatedClient.metadata.policy_uri;
if (updatedClient.metadata.tos_uri) response.tos_uri = updatedClient.metadata.tos_uri;
if (updatedClient.metadata.jwks_uri) response.jwks_uri = updatedClient.metadata.jwks_uri;
if (updatedClient.metadata.jwks) response.jwks = updatedClient.metadata.jwks;
}
res.json(response);
} catch (error) {
console.error('Update client configuration error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Failed to update client configuration',
});
}
};
/**
* DELETE /oauth/register/:clientId
* RFC 7591 Client Delete Endpoint
* Delete client registration
*/
export const deleteClientRegistration = async (req: Request, res: Response): Promise<void> => {
try {
const { clientId } = req.params;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Registration access token required',
});
return;
}
const token = authHeader.substring(7);
const tokenClientId = verifyRegistrationToken(token);
if (!tokenClientId || tokenClientId !== clientId) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired registration access token',
});
return;
}
const deleted = await deleteOAuthClient(clientId);
if (!deleted) {
res.status(404).json({
error: 'invalid_client',
error_description: 'Client not found',
});
return;
}
// Clean up registration token
registrationTokens.delete(token);
res.status(204).send();
} catch (error) {
console.error('Delete client registration error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Failed to delete client registration',
});
}
};