Fix jwt-token
This commit is contained in:
325
server/middleware/ldap-auth.js
Normal file
325
server/middleware/ldap-auth.js
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* LDAP Authentication Provider
|
||||
* Direct integration with Active Directory LDAP
|
||||
*/
|
||||
|
||||
const ldap = require('ldapjs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
class LDAPAuth {
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LDAP client connection
|
||||
*/
|
||||
createLDAPClient(config) {
|
||||
const clientOptions = {
|
||||
url: config.url, // ldap://dc.company.com:389 or ldaps://dc.company.com:636
|
||||
timeout: config.timeout || 5000,
|
||||
connectTimeout: config.connect_timeout || 10000,
|
||||
tlsOptions: {
|
||||
rejectUnauthorized: config.tls_reject_unauthorized !== false
|
||||
}
|
||||
};
|
||||
|
||||
if (config.ca_cert) {
|
||||
clientOptions.tlsOptions.ca = [config.ca_cert];
|
||||
}
|
||||
|
||||
return ldap.createClient(clientOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user against LDAP
|
||||
*/
|
||||
async authenticateUser(tenantId, username, password, config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = this.createLDAPClient(config);
|
||||
|
||||
// Build user DN
|
||||
const userDN = this.buildUserDN(username, config);
|
||||
|
||||
client.bind(userDN, password, (err) => {
|
||||
if (err) {
|
||||
client.unbind();
|
||||
return reject(new Error('Invalid credentials'));
|
||||
}
|
||||
|
||||
// Search for user details
|
||||
this.searchUser(client, username, config)
|
||||
.then(userInfo => {
|
||||
client.unbind();
|
||||
resolve(userInfo);
|
||||
})
|
||||
.catch(searchErr => {
|
||||
client.unbind();
|
||||
reject(searchErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build user Distinguished Name (DN)
|
||||
*/
|
||||
buildUserDN(username, config) {
|
||||
// Method 1: UPN format (user@domain.com)
|
||||
if (config.user_dn_format === 'upn') {
|
||||
return `${username}@${config.domain}`;
|
||||
}
|
||||
|
||||
// Method 2: CN format (CN=username,OU=Users,DC=domain,DC=com)
|
||||
if (config.user_dn_template) {
|
||||
return config.user_dn_template.replace('{username}', username);
|
||||
}
|
||||
|
||||
// Method 3: sAMAccountName format (DOMAIN\\username)
|
||||
if (config.domain) {
|
||||
return `${config.domain}\\${username}`;
|
||||
}
|
||||
|
||||
// Default: assume username is already a DN
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for user information in LDAP
|
||||
*/
|
||||
async searchUser(client, username, config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const baseDN = config.base_dn || 'dc=example,dc=com';
|
||||
const searchFilter = config.user_search_filter || `(sAMAccountName=${username})`;
|
||||
|
||||
const searchOptions = {
|
||||
filter: searchFilter.replace('{username}', username),
|
||||
scope: 'sub',
|
||||
attributes: [
|
||||
'sAMAccountName',
|
||||
'userPrincipalName',
|
||||
'mail',
|
||||
'givenName',
|
||||
'sn',
|
||||
'displayName',
|
||||
'telephoneNumber',
|
||||
'memberOf',
|
||||
'objectClass',
|
||||
'distinguishedName'
|
||||
]
|
||||
};
|
||||
|
||||
client.search(baseDN, searchOptions, (err, searchRes) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
let userEntry = null;
|
||||
|
||||
searchRes.on('searchEntry', (entry) => {
|
||||
userEntry = entry.object;
|
||||
});
|
||||
|
||||
searchRes.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
searchRes.on('end', (result) => {
|
||||
if (!userEntry) {
|
||||
return reject(new Error('User not found in directory'));
|
||||
}
|
||||
|
||||
// Extract user information
|
||||
const userInfo = {
|
||||
id: userEntry.distinguishedName,
|
||||
username: userEntry.sAMAccountName || userEntry.userPrincipalName,
|
||||
email: userEntry.mail,
|
||||
firstName: userEntry.givenName,
|
||||
lastName: userEntry.sn,
|
||||
displayName: userEntry.displayName,
|
||||
phoneNumber: userEntry.telephoneNumber,
|
||||
groups: this.extractGroups(userEntry.memberOf),
|
||||
distinguishedName: userEntry.distinguishedName,
|
||||
raw: userEntry
|
||||
};
|
||||
|
||||
resolve(userInfo);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract group names from memberOf attribute
|
||||
*/
|
||||
extractGroups(memberOf) {
|
||||
if (!memberOf) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups = Array.isArray(memberOf) ? memberOf : [memberOf];
|
||||
|
||||
return groups.map(dn => {
|
||||
// Extract CN from DN (e.g., "CN=Domain Admins,CN=Users,DC=domain,DC=com" -> "Domain Admins")
|
||||
const cnMatch = dn.match(/^CN=([^,]+)/i);
|
||||
return cnMatch ? cnMatch[1] : dn;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LDAP connection
|
||||
*/
|
||||
async testConnection(config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = this.createLDAPClient(config);
|
||||
|
||||
const testDN = config.bind_dn || config.admin_dn;
|
||||
const testPassword = config.bind_password || config.admin_password;
|
||||
|
||||
if (!testDN || !testPassword) {
|
||||
client.unbind();
|
||||
return reject(new Error('Admin credentials required for connection test'));
|
||||
}
|
||||
|
||||
client.bind(testDN, testPassword, (err) => {
|
||||
client.unbind();
|
||||
|
||||
if (err) {
|
||||
reject(new Error(`LDAP connection failed: ${err.message}`));
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle LDAP authentication request
|
||||
*/
|
||||
async authenticate(req, res, next) {
|
||||
// LDAP authentication is typically used for login forms
|
||||
// This middleware would be used in login POST endpoint
|
||||
|
||||
if (req.method !== 'POST' || !req.body.username || !req.body.password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username and password required for LDAP authentication'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tenantId = req.tenant.id;
|
||||
const config = req.tenant.authConfig.config;
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Authenticate against LDAP
|
||||
const userInfo = await this.authenticateUser(tenantId, username, password, config);
|
||||
|
||||
const { MultiTenantAuth } = require('./multi-tenant-auth');
|
||||
const multiAuth = new MultiTenantAuth();
|
||||
|
||||
// Create or update user
|
||||
const user = await multiAuth.createOrUpdateExternalUser(
|
||||
tenantId,
|
||||
userInfo,
|
||||
req.tenant.authConfig
|
||||
);
|
||||
|
||||
// Generate JWT token
|
||||
const token = multiAuth.generateJWTToken(user, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name
|
||||
},
|
||||
token,
|
||||
expires_in: '24h'
|
||||
},
|
||||
message: 'LDAP authentication successful'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('LDAP authentication error:', error);
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: 'LDAP authentication failed',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Invalid credentials'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync users from LDAP directory
|
||||
*/
|
||||
async syncUsers(tenantId, config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = this.createLDAPClient(config);
|
||||
|
||||
// Bind with admin credentials
|
||||
const adminDN = config.bind_dn || config.admin_dn;
|
||||
const adminPassword = config.bind_password || config.admin_password;
|
||||
|
||||
client.bind(adminDN, adminPassword, (err) => {
|
||||
if (err) {
|
||||
client.unbind();
|
||||
return reject(new Error('Failed to bind with admin credentials'));
|
||||
}
|
||||
|
||||
const baseDN = config.base_dn;
|
||||
const userFilter = config.user_sync_filter || '(objectClass=user)';
|
||||
|
||||
const searchOptions = {
|
||||
filter: userFilter,
|
||||
scope: 'sub',
|
||||
attributes: [
|
||||
'sAMAccountName', 'userPrincipalName', 'mail', 'givenName',
|
||||
'sn', 'displayName', 'telephoneNumber', 'memberOf', 'distinguishedName'
|
||||
]
|
||||
};
|
||||
|
||||
const users = [];
|
||||
|
||||
client.search(baseDN, searchOptions, (err, searchRes) => {
|
||||
if (err) {
|
||||
client.unbind();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
searchRes.on('searchEntry', (entry) => {
|
||||
const userEntry = entry.object;
|
||||
users.push({
|
||||
id: userEntry.distinguishedName,
|
||||
username: userEntry.sAMAccountName,
|
||||
email: userEntry.mail,
|
||||
firstName: userEntry.givenName,
|
||||
lastName: userEntry.sn,
|
||||
displayName: userEntry.displayName,
|
||||
phoneNumber: userEntry.telephoneNumber,
|
||||
groups: this.extractGroups(userEntry.memberOf)
|
||||
});
|
||||
});
|
||||
|
||||
searchRes.on('error', (err) => {
|
||||
client.unbind();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
searchRes.on('end', () => {
|
||||
client.unbind();
|
||||
resolve(users);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LDAPAuth;
|
||||
284
server/middleware/multi-tenant-auth.js
Normal file
284
server/middleware/multi-tenant-auth.js
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Multi-Tenant Authentication Middleware
|
||||
* Routes authentication requests to appropriate providers
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { User, Tenant } = require('../models');
|
||||
const { AuthConfig, AuthProviders } = require('../config/auth-providers');
|
||||
const SAMLAuth = require('./saml-auth');
|
||||
const OAuthAuth = require('./oauth-auth');
|
||||
const LDAPAuth = require('./ldap-auth');
|
||||
|
||||
class MultiTenantAuth {
|
||||
constructor() {
|
||||
this.authConfig = new AuthConfig();
|
||||
this.providers = new Map();
|
||||
|
||||
// Initialize authentication providers
|
||||
this.initializeProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all authentication providers
|
||||
*/
|
||||
initializeProviders() {
|
||||
this.providers.set(AuthProviders.SAML, new SAMLAuth());
|
||||
this.providers.set(AuthProviders.OAUTH, new OAuthAuth());
|
||||
this.providers.set(AuthProviders.LDAP, new LDAPAuth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine tenant from request
|
||||
* Can be from subdomain, header, or JWT
|
||||
*/
|
||||
async determineTenant(req) {
|
||||
// Method 1: Subdomain (tenant.yourapp.com)
|
||||
const subdomain = req.hostname.split('.')[0];
|
||||
if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
// Method 2: Custom header
|
||||
const tenantHeader = req.headers['x-tenant-id'];
|
||||
if (tenantHeader) {
|
||||
return tenantHeader;
|
||||
}
|
||||
|
||||
// Method 3: From JWT token (for existing sessions)
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
return decoded.tenantId;
|
||||
} catch (error) {
|
||||
// Token invalid, continue with other methods
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Query parameter (for redirects)
|
||||
if (req.query.tenant) {
|
||||
return req.query.tenant;
|
||||
}
|
||||
|
||||
// Default to 'default' tenant for backward compatibility
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication configuration for tenant
|
||||
*/
|
||||
async getTenantAuthConfig(tenantId) {
|
||||
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
|
||||
if (!tenant) {
|
||||
// Return default local auth for unknown tenants
|
||||
return {
|
||||
type: AuthProviders.LOCAL,
|
||||
enabled: true,
|
||||
config: {},
|
||||
userMapping: this.authConfig.getDefaultUserMapping(),
|
||||
roleMapping: this.authConfig.getDefaultRoleMapping()
|
||||
};
|
||||
}
|
||||
|
||||
return this.authConfig.getProvider(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user based on tenant configuration
|
||||
*/
|
||||
async authenticate(req, res, next) {
|
||||
try {
|
||||
const tenantId = await this.determineTenant(req);
|
||||
const authConfig = await this.getTenantAuthConfig(tenantId);
|
||||
|
||||
// Attach tenant info to request
|
||||
req.tenant = { id: tenantId, authConfig };
|
||||
|
||||
// Route to appropriate authentication provider
|
||||
switch (authConfig.type) {
|
||||
case AuthProviders.LOCAL:
|
||||
return this.authenticateLocal(req, res, next);
|
||||
|
||||
case AuthProviders.SAML:
|
||||
return this.authenticateSAML(req, res, next);
|
||||
|
||||
case AuthProviders.OAUTH:
|
||||
return this.authenticateOAuth(req, res, next);
|
||||
|
||||
case AuthProviders.LDAP:
|
||||
return this.authenticateLDAP(req, res, next);
|
||||
|
||||
default:
|
||||
return this.authenticateLocal(req, res, next);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Multi-tenant auth error:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Authentication service error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local JWT authentication (existing system)
|
||||
*/
|
||||
async authenticateLocal(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Access token required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await User.findByPk(decoded.userId, {
|
||||
attributes: ['id', 'username', 'email', 'role', 'is_active', 'tenant_id']
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid or inactive user'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user belongs to tenant
|
||||
if (user.tenant_id !== req.tenant.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'User not authorized for this tenant'
|
||||
});
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('JWT verification error:', error);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SAML authentication for Active Directory
|
||||
*/
|
||||
async authenticateSAML(req, res, next) {
|
||||
const provider = this.providers.get(AuthProviders.SAML);
|
||||
return provider.authenticate(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.0/OpenID Connect authentication
|
||||
*/
|
||||
async authenticateOAuth(req, res, next) {
|
||||
const provider = this.providers.get(AuthProviders.OAUTH);
|
||||
return provider.authenticate(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP authentication
|
||||
*/
|
||||
async authenticateLDAP(req, res, next) {
|
||||
const provider = this.providers.get(AuthProviders.LDAP);
|
||||
return provider.authenticate(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user from external provider
|
||||
*/
|
||||
async createOrUpdateExternalUser(tenantId, externalUser, authConfig) {
|
||||
const userMapping = authConfig.userMapping;
|
||||
const roleMapping = authConfig.roleMapping;
|
||||
|
||||
// Map external user attributes to internal user fields
|
||||
const userData = {
|
||||
username: this.getAttributeValue(externalUser, userMapping.username),
|
||||
email: this.getAttributeValue(externalUser, userMapping.email),
|
||||
first_name: this.getAttributeValue(externalUser, userMapping.firstName),
|
||||
last_name: this.getAttributeValue(externalUser, userMapping.lastName),
|
||||
phone_number: this.getAttributeValue(externalUser, userMapping.phoneNumber),
|
||||
tenant_id: tenantId,
|
||||
is_active: true,
|
||||
external_provider: authConfig.type,
|
||||
external_id: externalUser.id || externalUser.sub || externalUser.nameID,
|
||||
last_login: new Date()
|
||||
};
|
||||
|
||||
// Map roles from external provider
|
||||
const externalRoles = externalUser.roles || externalUser.groups || [];
|
||||
userData.role = this.mapExternalRole(externalRoles, roleMapping);
|
||||
|
||||
// Find or create user
|
||||
const [user, created] = await User.findOrCreate({
|
||||
where: {
|
||||
external_id: userData.external_id,
|
||||
tenant_id: tenantId
|
||||
},
|
||||
defaults: userData
|
||||
});
|
||||
|
||||
// Update existing user
|
||||
if (!created) {
|
||||
await user.update(userData);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attribute value from external user object
|
||||
*/
|
||||
getAttributeValue(externalUser, attributeNames) {
|
||||
if (!Array.isArray(attributeNames)) {
|
||||
attributeNames = [attributeNames];
|
||||
}
|
||||
|
||||
for (const attrName of attributeNames) {
|
||||
if (externalUser[attrName]) {
|
||||
return externalUser[attrName];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map external roles to internal roles
|
||||
*/
|
||||
mapExternalRole(externalRoles, roleMapping) {
|
||||
for (const externalRole of externalRoles) {
|
||||
if (roleMapping[externalRole]) {
|
||||
return roleMapping[externalRole];
|
||||
}
|
||||
}
|
||||
|
||||
return roleMapping.default || 'viewer';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token for authenticated user
|
||||
*/
|
||||
generateJWTToken(user, tenantId) {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tenantId: tenantId,
|
||||
provider: user.external_provider || 'local'
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MultiTenantAuth;
|
||||
245
server/middleware/oauth-auth.js
Normal file
245
server/middleware/oauth-auth.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* OAuth 2.0/OpenID Connect Authentication Provider
|
||||
* Supports modern identity providers like Azure AD, Google, etc.
|
||||
*/
|
||||
|
||||
const passport = require('passport');
|
||||
const OAuth2Strategy = require('passport-oauth2');
|
||||
const OpenIDConnectStrategy = require('passport-openidconnect');
|
||||
|
||||
class OAuthAuth {
|
||||
constructor() {
|
||||
this.strategies = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OAuth strategy for a tenant
|
||||
*/
|
||||
configureOAuthStrategy(tenantId, config) {
|
||||
const strategyName = `oauth-${tenantId}`;
|
||||
|
||||
// Determine if this is OpenID Connect or plain OAuth2
|
||||
const isOpenIDConnect = config.discovery_url || config.issuer;
|
||||
|
||||
if (isOpenIDConnect) {
|
||||
return this.configureOpenIDConnectStrategy(tenantId, config);
|
||||
} else {
|
||||
return this.configureOAuth2Strategy(tenantId, config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OpenID Connect strategy (recommended for modern providers)
|
||||
*/
|
||||
configureOpenIDConnectStrategy(tenantId, config) {
|
||||
const strategyName = `oidc-${tenantId}`;
|
||||
|
||||
const oidcOptions = {
|
||||
issuer: config.issuer || config.discovery_url,
|
||||
clientID: config.client_id,
|
||||
clientSecret: config.client_secret,
|
||||
callbackURL: `${process.env.BASE_URL}/auth/oauth/${tenantId}/callback`,
|
||||
|
||||
// Scopes
|
||||
scope: config.scopes || ['openid', 'profile', 'email'],
|
||||
|
||||
// Claims
|
||||
skipUserProfile: false,
|
||||
|
||||
// Additional parameters
|
||||
customHeaders: config.custom_headers || {},
|
||||
passReqToCallback: true
|
||||
};
|
||||
|
||||
const strategy = new OpenIDConnectStrategy(oidcOptions,
|
||||
async (req, issuer, profile, accessToken, refreshToken, done) => {
|
||||
try {
|
||||
const externalUser = {
|
||||
id: profile.id || profile.sub,
|
||||
username: profile.preferred_username || profile.username || profile.email,
|
||||
email: profile.email,
|
||||
firstName: profile.given_name || profile.firstName,
|
||||
lastName: profile.family_name || profile.lastName,
|
||||
displayName: profile.name || profile.displayName,
|
||||
roles: profile.roles || profile['custom:roles'] || [],
|
||||
groups: profile.groups || profile['custom:groups'] || [],
|
||||
raw: profile._json // Store raw profile for custom mapping
|
||||
};
|
||||
|
||||
return done(null, { tenantId, externalUser, provider: 'oauth' });
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
passport.use(strategyName, strategy);
|
||||
this.strategies.set(tenantId, { strategy, config, type: 'oidc' });
|
||||
|
||||
return strategyName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OAuth 2.0 strategy (for legacy providers)
|
||||
*/
|
||||
configureOAuth2Strategy(tenantId, config) {
|
||||
const strategyName = `oauth2-${tenantId}`;
|
||||
|
||||
const oauth2Options = {
|
||||
authorizationURL: config.authorization_url,
|
||||
tokenURL: config.token_url,
|
||||
clientID: config.client_id,
|
||||
clientSecret: config.client_secret,
|
||||
callbackURL: `${process.env.BASE_URL}/auth/oauth/${tenantId}/callback`,
|
||||
|
||||
// Custom parameters
|
||||
customHeaders: config.custom_headers || {},
|
||||
scope: config.scopes || ['profile', 'email'],
|
||||
passReqToCallback: true
|
||||
};
|
||||
|
||||
const strategy = new OAuth2Strategy(oauth2Options,
|
||||
async (req, accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
// Fetch user profile from userinfo endpoint
|
||||
const userProfile = await this.fetchUserProfile(accessToken, config.userinfo_url);
|
||||
|
||||
const externalUser = {
|
||||
id: userProfile.sub || userProfile.id,
|
||||
username: userProfile.preferred_username || userProfile.username || userProfile.email,
|
||||
email: userProfile.email,
|
||||
firstName: userProfile.given_name || userProfile.first_name,
|
||||
lastName: userProfile.family_name || userProfile.last_name,
|
||||
displayName: userProfile.name || userProfile.display_name,
|
||||
roles: userProfile.roles || [],
|
||||
groups: userProfile.groups || [],
|
||||
raw: userProfile
|
||||
};
|
||||
|
||||
return done(null, { tenantId, externalUser, provider: 'oauth' });
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
passport.use(strategyName, strategy);
|
||||
this.strategies.set(tenantId, { strategy, config, type: 'oauth2' });
|
||||
|
||||
return strategyName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile from OAuth userinfo endpoint
|
||||
*/
|
||||
async fetchUserProfile(accessToken, userinfoUrl) {
|
||||
const axios = require('axios');
|
||||
|
||||
try {
|
||||
const response = await axios.get(userinfoUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
throw new Error('Failed to fetch user profile');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth authentication
|
||||
*/
|
||||
async authenticate(req, res, next) {
|
||||
const tenantId = req.tenant.id;
|
||||
|
||||
// Check if strategy is configured for this tenant
|
||||
if (!this.strategies.has(tenantId)) {
|
||||
const config = req.tenant.authConfig.config;
|
||||
this.configureOAuthStrategy(tenantId, config);
|
||||
}
|
||||
|
||||
const strategyInfo = this.strategies.get(tenantId);
|
||||
const strategyName = strategyInfo.type === 'oidc' ? `oidc-${tenantId}` : `oauth2-${tenantId}`;
|
||||
|
||||
// Store return URL in session
|
||||
req.session.returnUrl = req.query.returnUrl || '/';
|
||||
|
||||
// Use passport to authenticate
|
||||
passport.authenticate(strategyName, {
|
||||
state: req.query.returnUrl || '/'
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback
|
||||
*/
|
||||
async handleCallback(req, res, next) {
|
||||
const tenantId = req.params.tenantId;
|
||||
const strategyInfo = this.strategies.get(tenantId);
|
||||
|
||||
if (!strategyInfo) {
|
||||
return res.redirect(`/login?error=strategy_not_found&tenant=${tenantId}`);
|
||||
}
|
||||
|
||||
const strategyName = strategyInfo.type === 'oidc' ? `oidc-${tenantId}` : `oauth2-${tenantId}`;
|
||||
|
||||
passport.authenticate(strategyName, async (err, authResult) => {
|
||||
if (err) {
|
||||
console.error('OAuth authentication error:', err);
|
||||
return res.redirect(`/login?error=auth_failed&tenant=${tenantId}`);
|
||||
}
|
||||
|
||||
if (!authResult) {
|
||||
return res.redirect(`/login?error=auth_cancelled&tenant=${tenantId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { MultiTenantAuth } = require('./multi-tenant-auth');
|
||||
const multiAuth = new MultiTenantAuth();
|
||||
|
||||
// Create or update user
|
||||
const user = await multiAuth.createOrUpdateExternalUser(
|
||||
tenantId,
|
||||
authResult.externalUser,
|
||||
req.tenant.authConfig
|
||||
);
|
||||
|
||||
// Generate JWT token
|
||||
const token = multiAuth.generateJWTToken(user, tenantId);
|
||||
|
||||
// Redirect to application with token
|
||||
const returnUrl = req.session.returnUrl || req.query.state || '/';
|
||||
res.redirect(`${returnUrl}?token=${token}&tenant=${tenantId}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('OAuth user creation error:', error);
|
||||
return res.redirect(`/login?error=user_creation_failed&tenant=${tenantId}`);
|
||||
}
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization URL for manual OAuth flow
|
||||
*/
|
||||
getAuthorizationUrl(tenantId, returnUrl) {
|
||||
const strategyInfo = this.strategies.get(tenantId);
|
||||
if (!strategyInfo) {
|
||||
throw new Error(`OAuth strategy not configured for tenant: ${tenantId}`);
|
||||
}
|
||||
|
||||
const config = strategyInfo.config;
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.client_id,
|
||||
response_type: 'code',
|
||||
redirect_uri: `${process.env.BASE_URL}/auth/oauth/${tenantId}/callback`,
|
||||
scope: (config.scopes || ['openid', 'profile', 'email']).join(' '),
|
||||
state: returnUrl || '/'
|
||||
});
|
||||
|
||||
return `${config.authorization_url}?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuthAuth;
|
||||
197
server/middleware/saml-auth.js
Normal file
197
server/middleware/saml-auth.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* SAML Authentication Provider
|
||||
* Supports Active Directory integration via ADFS
|
||||
*/
|
||||
|
||||
const passport = require('passport');
|
||||
const SamlStrategy = require('passport-saml').Strategy;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class SAMLAuth {
|
||||
constructor() {
|
||||
this.strategies = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure SAML strategy for a tenant
|
||||
*/
|
||||
configureSAMLStrategy(tenantId, config) {
|
||||
const strategyName = `saml-${tenantId}`;
|
||||
|
||||
const samlOptions = {
|
||||
// SAML Service Provider (SP) Configuration
|
||||
callbackUrl: `${process.env.BASE_URL}/auth/saml/${tenantId}/callback`,
|
||||
entryPoint: config.sso_url, // ADFS SSO URL
|
||||
issuer: config.issuer || `urn:${process.env.DOMAIN_NAME}:${tenantId}`,
|
||||
|
||||
// Identity Provider (IdP) Configuration
|
||||
cert: config.certificate, // ADFS signing certificate
|
||||
|
||||
// Optional: Encryption
|
||||
privateCert: config.private_key,
|
||||
decryptionPvk: config.private_key,
|
||||
|
||||
// Attribute mapping
|
||||
attributeConsumingServiceIndex: false,
|
||||
disableRequestedAuthnContext: true,
|
||||
|
||||
// ADFS specific settings
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
acceptedClockSkewMs: 5000,
|
||||
|
||||
// Logout
|
||||
logoutUrl: config.logout_url,
|
||||
logoutCallbackUrl: `${process.env.BASE_URL}/auth/saml/${tenantId}/logout`
|
||||
};
|
||||
|
||||
const strategy = new SamlStrategy(samlOptions, async (profile, done) => {
|
||||
try {
|
||||
// Extract user information from SAML assertion
|
||||
const externalUser = {
|
||||
id: profile.nameID,
|
||||
username: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || profile.nameID,
|
||||
email: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || profile.email,
|
||||
firstName: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'] || profile.givenName,
|
||||
lastName: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'] || profile.surname,
|
||||
displayName: profile['http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname'] || profile.displayName,
|
||||
groups: this.extractGroups(profile),
|
||||
roles: this.extractRoles(profile)
|
||||
};
|
||||
|
||||
return done(null, { tenantId, externalUser, provider: 'saml' });
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
passport.use(strategyName, strategy);
|
||||
this.strategies.set(tenantId, { strategy, config });
|
||||
|
||||
return strategyName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Active Directory groups from SAML assertion
|
||||
*/
|
||||
extractGroups(profile) {
|
||||
const groupClaims = [
|
||||
'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups',
|
||||
'http://schemas.xmlsoap.org/claims/Group',
|
||||
'groups'
|
||||
];
|
||||
|
||||
for (const claim of groupClaims) {
|
||||
if (profile[claim]) {
|
||||
return Array.isArray(profile[claim]) ? profile[claim] : [profile[claim]];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract roles from SAML assertion
|
||||
*/
|
||||
extractRoles(profile) {
|
||||
const roleClaims = [
|
||||
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role',
|
||||
'http://schemas.xmlsoap.org/claims/Role',
|
||||
'roles'
|
||||
];
|
||||
|
||||
for (const claim of roleClaims) {
|
||||
if (profile[claim]) {
|
||||
return Array.isArray(profile[claim]) ? profile[claim] : [profile[claim]];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate SAML authentication
|
||||
*/
|
||||
async authenticate(req, res, next) {
|
||||
const tenantId = req.tenant.id;
|
||||
const strategyName = `saml-${tenantId}`;
|
||||
|
||||
// Check if strategy is configured for this tenant
|
||||
if (!this.strategies.has(tenantId)) {
|
||||
const config = req.tenant.authConfig.config;
|
||||
this.configureSAMLStrategy(tenantId, config);
|
||||
}
|
||||
|
||||
// Use passport to authenticate
|
||||
passport.authenticate(strategyName, {
|
||||
additionalParams: {
|
||||
RelayState: req.query.returnUrl || '/'
|
||||
}
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SAML callback
|
||||
*/
|
||||
async handleCallback(req, res, next) {
|
||||
const tenantId = req.params.tenantId;
|
||||
const strategyName = `saml-${tenantId}`;
|
||||
|
||||
passport.authenticate(strategyName, async (err, authResult) => {
|
||||
if (err) {
|
||||
console.error('SAML authentication error:', err);
|
||||
return res.redirect(`/login?error=auth_failed&tenant=${tenantId}`);
|
||||
}
|
||||
|
||||
if (!authResult) {
|
||||
return res.redirect(`/login?error=auth_cancelled&tenant=${tenantId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { MultiTenantAuth } = require('./multi-tenant-auth');
|
||||
const multiAuth = new MultiTenantAuth();
|
||||
|
||||
// Create or update user
|
||||
const user = await multiAuth.createOrUpdateExternalUser(
|
||||
tenantId,
|
||||
authResult.externalUser,
|
||||
req.tenant.authConfig
|
||||
);
|
||||
|
||||
// Generate JWT token
|
||||
const token = multiAuth.generateJWTToken(user, tenantId);
|
||||
|
||||
// Redirect to application with token
|
||||
const returnUrl = req.body.RelayState || '/';
|
||||
res.redirect(`${returnUrl}?token=${token}&tenant=${tenantId}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('SAML user creation error:', error);
|
||||
return res.redirect(`/login?error=user_creation_failed&tenant=${tenantId}`);
|
||||
}
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SAML metadata for SP configuration
|
||||
*/
|
||||
generateMetadata(tenantId, config) {
|
||||
const metadata = `<?xml version="1.0"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||
entityID="urn:${process.env.DOMAIN_NAME}:${tenantId}">
|
||||
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true"
|
||||
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="${process.env.BASE_URL}/auth/saml/${tenantId}/callback"
|
||||
index="1" />
|
||||
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
Location="${process.env.BASE_URL}/auth/saml/${tenantId}/logout" />
|
||||
</md:SPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SAMLAuth;
|
||||
Reference in New Issue
Block a user