feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
This commit is contained in:
568
ts/services/external.auth.service.ts
Normal file
568
ts/services/external.auth.service.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* External Auth Service for Stack.Gallery Registry
|
||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
import { cryptoService } from './crypto.service.ts';
|
||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthState {
|
||||
providerId: string;
|
||||
returnUrl?: string;
|
||||
nonce: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export class ExternalAuthService {
|
||||
private strategyFactory: AuthStrategyFactory;
|
||||
private authService: AuthService;
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor() {
|
||||
this.strategyFactory = new AuthStrategyFactory(cryptoService);
|
||||
this.authService = new AuthService();
|
||||
this.auditService = new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow - returns authorization URL and state
|
||||
*/
|
||||
public async initiateOAuth(
|
||||
providerId: string,
|
||||
returnUrl?: string
|
||||
): Promise<{ authUrl: string; state: string }> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
if (provider.status !== 'active') {
|
||||
throw new Error('Provider is not active');
|
||||
}
|
||||
|
||||
if (provider.type !== 'oidc') {
|
||||
throw new Error('Provider is not an OAuth/OIDC provider');
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.getAuthorizationUrl) {
|
||||
throw new Error('Provider does not support OAuth flow');
|
||||
}
|
||||
|
||||
// Generate state with encoded data
|
||||
const state = await this.generateState(providerId, returnUrl);
|
||||
const nonce = crypto.randomUUID();
|
||||
|
||||
const authUrl = await strategy.getAuthorizationUrl(state, nonce);
|
||||
|
||||
return { authUrl, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for user and create session
|
||||
*/
|
||||
public async handleOAuthCallback(
|
||||
data: IOAuthCallbackData,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
// Validate state
|
||||
const stateData = await this.validateState(data.state);
|
||||
if (!stateData) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_STATE',
|
||||
errorMessage: 'Invalid or expired state',
|
||||
};
|
||||
}
|
||||
|
||||
// Get provider
|
||||
const provider = await AuthProvider.findById(stateData.providerId);
|
||||
if (!provider || provider.status !== 'active') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_INACTIVE',
|
||||
errorMessage: 'Provider not found or inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle OAuth callback
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.handleCallback) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support OAuth callback',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.handleCallback(data);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens using the existing AuthService approach
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'oauth',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with LDAP credentials
|
||||
*/
|
||||
public async authenticateLdap(
|
||||
providerId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Invalid LDAP provider',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.authenticateCredentials) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support credential authentication',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.authenticateCredentials(username, password);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
username,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'AUTH_FAILED',
|
||||
errorMessage: 'Invalid credentials',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'ldap',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an external provider to an existing user
|
||||
*/
|
||||
public async linkProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
externalUser: IExternalUserInfo
|
||||
): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked to another user
|
||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||
if (existing) {
|
||||
if (existing.userId === userId) {
|
||||
// Already linked to this user, just update
|
||||
await existing.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
throw new Error('This external account is already linked to another user');
|
||||
}
|
||||
|
||||
// Create new identity link
|
||||
const identity = await ExternalIdentity.createIdentity({
|
||||
userId,
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
|
||||
// Update user's external identity IDs
|
||||
const user = await User.findById(userId);
|
||||
if (user) {
|
||||
user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id];
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'link_provider',
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
},
|
||||
});
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink an external provider from a user
|
||||
*/
|
||||
public async unlinkProvider(userId: string, providerId: string): Promise<boolean> {
|
||||
const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId);
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure user still has another auth method
|
||||
const user = await User.findById(userId);
|
||||
if (!user) return false;
|
||||
|
||||
const otherIdentities = await ExternalIdentity.findByUserId(userId);
|
||||
const hasLocalAuth = user.canUseLocalAuth && user.passwordHash;
|
||||
|
||||
if (otherIdentities.length <= 1 && !hasLocalAuth) {
|
||||
throw new Error('Cannot unlink last authentication method');
|
||||
}
|
||||
|
||||
// Remove identity
|
||||
await identity.delete();
|
||||
|
||||
// Update user's external identity IDs
|
||||
user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id);
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'unlink_provider',
|
||||
providerId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
public async testConnection(providerId: string): Promise<IConnectionTestResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: 0,
|
||||
error: 'Provider not found',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
const result = await strategy.testConnection();
|
||||
|
||||
// Update provider test status
|
||||
await provider.updateTestResult(result.success, result.error);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create user from external authentication
|
||||
*/
|
||||
private async findOrCreateUser(
|
||||
provider: AuthProvider,
|
||||
externalUser: IExternalUserInfo,
|
||||
options: { ipAddress?: string } = {}
|
||||
): Promise<{ user: User; isNew: boolean }> {
|
||||
// 1. Check if external identity already exists
|
||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||
provider.id,
|
||||
externalUser.externalId
|
||||
);
|
||||
|
||||
if (existingIdentity) {
|
||||
const user = await User.findById(existingIdentity.userId);
|
||||
if (user) {
|
||||
// Update identity with latest info
|
||||
await existingIdentity.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return { user, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try to link by email if enabled
|
||||
if (provider.provisioning.autoLinkByEmail && externalUser.email) {
|
||||
const existingUser = await User.findByEmail(externalUser.email);
|
||||
if (existingUser) {
|
||||
await this.linkProvider(existingUser.id, provider.id, externalUser);
|
||||
return { user: existingUser, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create new user if JIT is enabled
|
||||
if (!provider.provisioning.jitEnabled) {
|
||||
throw new Error('User not found and JIT provisioning is disabled');
|
||||
}
|
||||
|
||||
// Check domain restrictions
|
||||
if (provider.provisioning.allowedEmailDomains?.length) {
|
||||
const domain = externalUser.email.split('@')[1];
|
||||
if (!provider.provisioning.allowedEmailDomains.includes(domain)) {
|
||||
throw new Error(`Email domain ${domain} is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique username
|
||||
let username = externalUser.username || externalUser.email.split('@')[0];
|
||||
username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Ensure username is unique
|
||||
let counter = 0;
|
||||
let finalUsername = username;
|
||||
while (await User.findByUsername(finalUsername)) {
|
||||
counter++;
|
||||
finalUsername = `${username}${counter}`;
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = externalUser.email.toLowerCase();
|
||||
user.username = finalUsername;
|
||||
user.displayName = externalUser.displayName || finalUsername;
|
||||
user.avatarUrl = externalUser.avatarUrl;
|
||||
user.status = 'active';
|
||||
user.emailVerified = true; // Trust the provider
|
||||
user.canUseLocalAuth = false; // No password set
|
||||
user.provisionedByProviderId = provider.id;
|
||||
user.passwordHash = ''; // No local password
|
||||
user.createdAt = new Date();
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Link external identity
|
||||
await this.linkProvider(user.id, provider.id, externalUser);
|
||||
|
||||
return { user, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth state token
|
||||
*/
|
||||
private async generateState(providerId: string, returnUrl?: string): Promise<string> {
|
||||
const stateData: IOAuthState = {
|
||||
providerId,
|
||||
returnUrl,
|
||||
nonce: crypto.randomUUID(),
|
||||
exp: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||
};
|
||||
|
||||
// Encode as base64
|
||||
return btoa(JSON.stringify(stateData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state token
|
||||
*/
|
||||
private async validateState(state: string): Promise<IOAuthState | null> {
|
||||
try {
|
||||
const stateData: IOAuthState = JSON.parse(atob(state));
|
||||
|
||||
// Check expiration
|
||||
if (stateData.exp < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stateData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 15 * 60; // 15 minutes
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'refresh',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign JWT token
|
||||
*/
|
||||
private async signJwt(payload: Record<string, unknown>, secret: string): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
|
||||
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const encodedSignature = this.base64UrlEncode(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
);
|
||||
|
||||
return `${data}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private base64UrlEncode(str: string): string {
|
||||
const base64 = btoa(str);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const externalAuthService = new ExternalAuthService();
|
||||
Reference in New Issue
Block a user