mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-23 18:29:21 -05:00
544 lines
16 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
};
|