Files
registry/ts/services/external.auth.service.ts

569 lines
16 KiB
TypeScript
Raw Normal View History

/**
* 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();