mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com> Co-authored-by: samanhappy <samanhappy@gmail.com>
436 lines
11 KiB
TypeScript
436 lines
11 KiB
TypeScript
import OAuth2Server from '@node-oauth/oauth2-server';
|
|
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
|
import { loadSettings } from '../config/index.js';
|
|
import { findUserByUsername, verifyPassword } from '../models/User.js';
|
|
import {
|
|
findOAuthClientById,
|
|
saveAuthorizationCode,
|
|
getAuthorizationCode,
|
|
revokeAuthorizationCode,
|
|
saveToken,
|
|
getToken,
|
|
revokeToken,
|
|
} from '../models/OAuth.js';
|
|
import crypto from 'crypto';
|
|
|
|
const { Request, Response } = OAuth2Server;
|
|
|
|
// OAuth2Server model implementation
|
|
const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshTokenModel = {
|
|
/**
|
|
* Get client by client ID
|
|
*/
|
|
getClient: async (clientId: string, clientSecret?: string) => {
|
|
const client = findOAuthClientById(clientId);
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
|
|
// If client secret is provided, verify it
|
|
if (clientSecret && client.clientSecret) {
|
|
if (client.clientSecret !== clientSecret) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: client.clientId,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
redirectUris: client.redirectUris,
|
|
grants: client.grants,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Save authorization code
|
|
*/
|
|
saveAuthorizationCode: async (
|
|
code: OAuth2Server.AuthorizationCode,
|
|
client: OAuth2Server.Client,
|
|
user: OAuth2Server.User,
|
|
) => {
|
|
const settings = loadSettings();
|
|
const oauthConfig = settings.systemConfig?.oauthServer;
|
|
const lifetime = oauthConfig?.authorizationCodeLifetime || 300;
|
|
|
|
const scopeString = Array.isArray(code.scope) ? code.scope.join(' ') : code.scope;
|
|
|
|
const authCode = saveAuthorizationCode(
|
|
{
|
|
redirectUri: code.redirectUri,
|
|
scope: scopeString,
|
|
clientId: client.id,
|
|
username: user.username,
|
|
codeChallenge: code.codeChallenge,
|
|
codeChallengeMethod: code.codeChallengeMethod,
|
|
},
|
|
lifetime,
|
|
);
|
|
|
|
return {
|
|
authorizationCode: authCode,
|
|
expiresAt: new Date(Date.now() + lifetime * 1000),
|
|
redirectUri: code.redirectUri,
|
|
scope: code.scope,
|
|
client,
|
|
user: {
|
|
username: user.username,
|
|
},
|
|
codeChallenge: code.codeChallenge,
|
|
codeChallengeMethod: code.codeChallengeMethod,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get authorization code
|
|
*/
|
|
getAuthorizationCode: async (authorizationCode: string) => {
|
|
const code = getAuthorizationCode(authorizationCode);
|
|
if (!code) {
|
|
return false;
|
|
}
|
|
|
|
const client = findOAuthClientById(code.clientId);
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
|
|
const scopeArray = code.scope ? code.scope.split(' ') : undefined;
|
|
|
|
return {
|
|
authorizationCode: code.code,
|
|
expiresAt: code.expiresAt,
|
|
redirectUri: code.redirectUri,
|
|
scope: scopeArray,
|
|
client: {
|
|
id: client.clientId,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
redirectUris: client.redirectUris,
|
|
grants: client.grants,
|
|
},
|
|
user: {
|
|
username: code.username,
|
|
},
|
|
codeChallenge: code.codeChallenge,
|
|
codeChallengeMethod: code.codeChallengeMethod,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Revoke authorization code
|
|
*/
|
|
revokeAuthorizationCode: async (code: OAuth2Server.AuthorizationCode) => {
|
|
revokeAuthorizationCode(code.authorizationCode);
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Save access token and refresh token
|
|
*/
|
|
saveToken: async (
|
|
token: OAuth2Server.Token,
|
|
client: OAuth2Server.Client,
|
|
user: OAuth2Server.User,
|
|
) => {
|
|
const settings = loadSettings();
|
|
const oauthConfig = settings.systemConfig?.oauthServer;
|
|
const accessTokenLifetime = oauthConfig?.accessTokenLifetime || 3600;
|
|
const refreshTokenLifetime = oauthConfig?.refreshTokenLifetime || 1209600;
|
|
|
|
const scopeString = Array.isArray(token.scope) ? token.scope.join(' ') : token.scope;
|
|
|
|
const savedToken = saveToken(
|
|
{
|
|
scope: scopeString,
|
|
clientId: client.id,
|
|
username: user.username,
|
|
},
|
|
accessTokenLifetime,
|
|
refreshTokenLifetime,
|
|
);
|
|
|
|
const scopeArray = savedToken.scope ? savedToken.scope.split(' ') : undefined;
|
|
|
|
return {
|
|
accessToken: savedToken.accessToken,
|
|
accessTokenExpiresAt: savedToken.accessTokenExpiresAt,
|
|
refreshToken: savedToken.refreshToken,
|
|
refreshTokenExpiresAt: savedToken.refreshTokenExpiresAt,
|
|
scope: scopeArray,
|
|
client,
|
|
user: {
|
|
username: user.username,
|
|
},
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get access token
|
|
*/
|
|
getAccessToken: async (accessToken: string) => {
|
|
const token = getToken(accessToken);
|
|
if (!token) {
|
|
return false;
|
|
}
|
|
|
|
const client = findOAuthClientById(token.clientId);
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
|
|
const scopeArray = token.scope ? token.scope.split(' ') : undefined;
|
|
|
|
return {
|
|
accessToken: token.accessToken,
|
|
accessTokenExpiresAt: token.accessTokenExpiresAt,
|
|
scope: scopeArray,
|
|
client: {
|
|
id: client.clientId,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
redirectUris: client.redirectUris,
|
|
grants: client.grants,
|
|
},
|
|
user: {
|
|
username: token.username,
|
|
},
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get refresh token
|
|
*/
|
|
getRefreshToken: async (refreshToken: string) => {
|
|
const token = getToken(refreshToken);
|
|
if (!token || token.refreshToken !== refreshToken) {
|
|
return false;
|
|
}
|
|
|
|
const client = findOAuthClientById(token.clientId);
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
|
|
const scopeArray = token.scope ? token.scope.split(' ') : undefined;
|
|
|
|
return {
|
|
refreshToken: token.refreshToken!,
|
|
refreshTokenExpiresAt: token.refreshTokenExpiresAt!,
|
|
scope: scopeArray,
|
|
client: {
|
|
id: client.clientId,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
redirectUris: client.redirectUris,
|
|
grants: client.grants,
|
|
},
|
|
user: {
|
|
username: token.username,
|
|
},
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Revoke token
|
|
*/
|
|
revokeToken: async (token: OAuth2Server.Token | OAuth2Server.RefreshToken) => {
|
|
const refreshToken = 'refreshToken' in token ? token.refreshToken : undefined;
|
|
if (refreshToken) {
|
|
revokeToken(refreshToken);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Verify scope
|
|
*/
|
|
verifyScope: async (token: OAuth2Server.Token, scope: string | string[]) => {
|
|
if (!token.scope) {
|
|
return false;
|
|
}
|
|
|
|
const requestedScopes = Array.isArray(scope) ? scope : scope.split(' ');
|
|
const tokenScopes = Array.isArray(token.scope) ? token.scope : (token.scope as string).split(' ');
|
|
|
|
return requestedScopes.every((s) => tokenScopes.includes(s));
|
|
},
|
|
|
|
/**
|
|
* Validate scope
|
|
*/
|
|
validateScope: async (user: OAuth2Server.User, client: OAuth2Server.Client, scope?: string[]) => {
|
|
const settings = loadSettings();
|
|
const oauthConfig = settings.systemConfig?.oauthServer;
|
|
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
|
|
|
|
if (!scope || scope.length === 0) {
|
|
return allowedScopes;
|
|
}
|
|
|
|
const validScopes = scope.filter((s) => allowedScopes.includes(s));
|
|
|
|
return validScopes.length > 0 ? validScopes : false;
|
|
},
|
|
};
|
|
|
|
// Create OAuth2 server instance
|
|
let oauth: OAuth2Server | null = null;
|
|
|
|
/**
|
|
* Initialize OAuth server
|
|
*/
|
|
export const initOAuthServer = (): void => {
|
|
const settings = loadSettings();
|
|
const oauthConfig = settings.systemConfig?.oauthServer;
|
|
const requireState = oauthConfig?.requireState === true;
|
|
|
|
if (!oauthConfig || !oauthConfig.enabled) {
|
|
console.log('OAuth authorization server is disabled or not configured');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
oauth = new OAuth2Server({
|
|
model: oauthModel,
|
|
accessTokenLifetime: oauthConfig.accessTokenLifetime || 3600,
|
|
refreshTokenLifetime: oauthConfig.refreshTokenLifetime || 1209600,
|
|
authorizationCodeLifetime: oauthConfig.authorizationCodeLifetime || 300,
|
|
allowEmptyState: !requireState,
|
|
allowBearerTokensInQueryString: false,
|
|
// When requireClientSecret is false, allow PKCE without client secret
|
|
requireClientAuthentication: oauthConfig.requireClientSecret
|
|
? { authorization_code: true, refresh_token: true }
|
|
: { authorization_code: false, refresh_token: false },
|
|
});
|
|
|
|
console.log('OAuth authorization server initialized successfully');
|
|
} catch (error) {
|
|
console.error('Failed to initialize OAuth authorization server:', error);
|
|
oauth = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get OAuth server instance
|
|
*/
|
|
export const getOAuthServer = (): OAuth2Server | null => {
|
|
return oauth;
|
|
};
|
|
|
|
/**
|
|
* Check if OAuth server is enabled
|
|
*/
|
|
export const isOAuthServerEnabled = (): boolean => {
|
|
return oauth !== null;
|
|
};
|
|
|
|
/**
|
|
* Authenticate user for OAuth authorization
|
|
*/
|
|
export const authenticateUser = async (
|
|
username: string,
|
|
password: string,
|
|
): Promise<OAuth2Server.User | null> => {
|
|
const user = findUserByUsername(username);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const isValid = await verifyPassword(password, user.password);
|
|
if (!isValid) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
username: user.username,
|
|
isAdmin: user.isAdmin,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Generate PKCE code verifier
|
|
*/
|
|
export const generateCodeVerifier = (): string => {
|
|
return crypto.randomBytes(32).toString('base64url');
|
|
};
|
|
|
|
/**
|
|
* Generate PKCE code challenge from verifier
|
|
*/
|
|
export const generateCodeChallenge = (verifier: string): string => {
|
|
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
};
|
|
|
|
/**
|
|
* Verify PKCE code challenge
|
|
*/
|
|
export const verifyCodeChallenge = (
|
|
verifier: string,
|
|
challenge: string,
|
|
method: string = 'S256',
|
|
): boolean => {
|
|
if (method === 'plain') {
|
|
return verifier === challenge;
|
|
}
|
|
|
|
if (method === 'S256') {
|
|
const computed = generateCodeChallenge(verifier);
|
|
return computed === challenge;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Handle OAuth authorize request
|
|
*/
|
|
export const handleAuthorizeRequest = async (
|
|
req: ExpressRequest,
|
|
res: ExpressResponse,
|
|
): Promise<OAuth2Server.AuthorizationCode> => {
|
|
if (!oauth) {
|
|
throw new Error('OAuth server not initialized');
|
|
}
|
|
|
|
const request = new Request(req);
|
|
const response = new Response(res);
|
|
|
|
return await oauth.authorize(request, response);
|
|
};
|
|
|
|
/**
|
|
* Handle OAuth token request
|
|
*/
|
|
export const handleTokenRequest = async (
|
|
req: ExpressRequest,
|
|
res: ExpressResponse,
|
|
): Promise<OAuth2Server.Token> => {
|
|
if (!oauth) {
|
|
throw new Error('OAuth server not initialized');
|
|
}
|
|
|
|
const request = new Request(req);
|
|
const response = new Response(res);
|
|
|
|
return await oauth.token(request, response);
|
|
};
|
|
|
|
/**
|
|
* Handle OAuth authenticate request (validate access token)
|
|
*/
|
|
export const handleAuthenticateRequest = async (
|
|
req: ExpressRequest,
|
|
res: ExpressResponse,
|
|
): Promise<OAuth2Server.Token> => {
|
|
if (!oauth) {
|
|
throw new Error('OAuth server not initialized');
|
|
}
|
|
|
|
const request = new Request(req);
|
|
const response = new Response(res);
|
|
|
|
return await oauth.authenticate(request, response);
|
|
};
|