From d3fd40ce2f65d0f3212a8a4debf34bafc62720d2 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 3 Dec 2025 22:09:35 +0000 Subject: [PATCH] feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support --- changelog.md | 15 + ts/00_commitinfo_data.ts | 2 +- ts/api/handlers/admin.auth.api.ts | 461 ++++++++++++ ts/api/handlers/oauth.api.ts | 188 +++++ ts/api/router.ts | 22 + ts/interfaces/auth.interfaces.ts | 137 ++++ ts/models/auth.provider.ts | 252 +++++++ ts/models/external.identity.ts | 142 ++++ ts/models/index.ts | 5 + ts/models/platform.settings.ts | 90 +++ ts/models/user.ts | 10 + .../strategies/auth.strategy.interface.ts | 47 ++ ts/services/auth/strategies/index.ts | 8 + ts/services/auth/strategies/ldap.strategy.ts | 242 ++++++ ts/services/auth/strategies/oauth.strategy.ts | 263 +++++++ .../auth/strategies/strategy.factory.ts | 28 + ts/services/crypto.service.ts | 178 +++++ ts/services/external.auth.service.ts | 568 ++++++++++++++ ui/src/app/app.routes.ts | 41 + ui/src/app/core/guards/admin.guard.ts | 27 + .../app/core/services/admin-auth.service.ts | 141 ++++ ui/src/app/core/services/auth.service.ts | 13 + .../auth-providers.component.ts | 522 +++++++++++++ .../auth-providers/provider-form.component.ts | 705 ++++++++++++++++++ ui/src/app/features/login/login.component.ts | 382 ++++++++-- .../oauth-callback.component.ts | 68 ++ .../components/layout/layout.component.ts | 16 +- 27 files changed, 4512 insertions(+), 61 deletions(-) create mode 100644 ts/api/handlers/admin.auth.api.ts create mode 100644 ts/api/handlers/oauth.api.ts create mode 100644 ts/models/auth.provider.ts create mode 100644 ts/models/external.identity.ts create mode 100644 ts/models/platform.settings.ts create mode 100644 ts/services/auth/strategies/auth.strategy.interface.ts create mode 100644 ts/services/auth/strategies/index.ts create mode 100644 ts/services/auth/strategies/ldap.strategy.ts create mode 100644 ts/services/auth/strategies/oauth.strategy.ts create mode 100644 ts/services/auth/strategies/strategy.factory.ts create mode 100644 ts/services/crypto.service.ts create mode 100644 ts/services/external.auth.service.ts create mode 100644 ui/src/app/core/guards/admin.guard.ts create mode 100644 ui/src/app/core/services/admin-auth.service.ts create mode 100644 ui/src/app/features/admin/auth-providers/auth-providers.component.ts create mode 100644 ui/src/app/features/admin/auth-providers/provider-form.component.ts create mode 100644 ui/src/app/features/oauth-callback/oauth-callback.component.ts diff --git a/changelog.md b/changelog.md index 0465bf8..bb41ef0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,20 @@ # Changelog +## 2025-12-03 - 1.3.0 - feat(auth) +Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support + +- Introduce external authentication models: AuthProvider, ExternalIdentity, PlatformSettings to store provider configs, links, and platform auth settings +- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage platform auth settings +- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling callbacks, and LDAP login +- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking, session/token generation, and provider testing +- Add pluggable auth strategy pattern with OAuthStrategy and LdapStrategy plus AuthStrategyFactory to select appropriate strategy +- Add CryptoService for AES-256-GCM encryption/decryption of provider secrets and helper for key generation +- Extend AuthService and session/user handling to support tokens/sessions created by external auth flows and user provisioning flags +- Add UI: admin pages for managing auth providers (list, provider form, connection test) and login enhancements (SSO buttons, LDAP form, oauth-callback handler) +- Add client-side AdminAuthService for communicating with new admin auth endpoints and an adminGuard for route protection +- Register new API routes in ApiRouter and wire server-side handlers into the router +- Implement safeguards: mask secrets in admin responses, validate provider configs, and track connection test results and audit logs + ## 2025-11-28 - 1.2.0 - feat(tokens) Add support for organization-owned API tokens and org-level token management diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3ed5472..1a42ea8 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@stack.gallery/registry', - version: '1.2.0', + version: '1.3.0', description: 'Enterprise-grade multi-protocol package registry' } diff --git a/ts/api/handlers/admin.auth.api.ts b/ts/api/handlers/admin.auth.api.ts new file mode 100644 index 0000000..c4b1750 --- /dev/null +++ b/ts/api/handlers/admin.auth.api.ts @@ -0,0 +1,461 @@ +/** + * Admin Auth API handlers + * Platform admin endpoints for managing authentication providers and settings + */ + +import type { IApiContext, IApiResponse } from '../router.ts'; +import { AuthProvider, PlatformSettings } from '../../models/index.ts'; +import { cryptoService } from '../../services/crypto.service.ts'; +import { externalAuthService } from '../../services/external.auth.service.ts'; +import { AuditService } from '../../services/audit.service.ts'; +import type { + ICreateAuthProviderDto, + IUpdateAuthProviderDto, +} from '../../interfaces/auth.interfaces.ts'; + +export class AdminAuthApi { + /** + * Check if actor is platform admin + */ + private requirePlatformAdmin(ctx: IApiContext): IApiResponse | null { + if (!ctx.actor?.userId || !ctx.actor.user?.isPlatformAdmin) { + return { + status: 403, + body: { error: 'Platform admin access required' }, + }; + } + return null; + } + + /** + * GET /api/v1/admin/auth/providers + * List all authentication providers + */ + public async listProviders(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const providers = await AuthProvider.getAllProviders(); + return { + status: 200, + body: { + providers: providers.map((p) => p.toAdminInfo()), + }, + }; + } catch (error) { + console.error('[AdminAuthApi] List providers error:', error); + return { + status: 500, + body: { error: 'Failed to list providers' }, + }; + } + } + + /** + * POST /api/v1/admin/auth/providers + * Create a new authentication provider + */ + public async createProvider(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const body = (await ctx.request.json()) as ICreateAuthProviderDto; + + // Validate required fields + if (!body.name || !body.displayName || !body.type) { + return { + status: 400, + body: { error: 'name, displayName, and type are required' }, + }; + } + + // Check name uniqueness + const existing = await AuthProvider.findByName(body.name); + if (existing) { + return { + status: 409, + body: { error: 'Provider name already exists' }, + }; + } + + // Validate type-specific config + if (body.type === 'oidc' && !body.oauthConfig) { + return { + status: 400, + body: { error: 'oauthConfig is required for OIDC provider' }, + }; + } + if (body.type === 'ldap' && !body.ldapConfig) { + return { + status: 400, + body: { error: 'ldapConfig is required for LDAP provider' }, + }; + } + + let provider: AuthProvider; + + if (body.type === 'oidc' && body.oauthConfig) { + // Encrypt client secret + const encryptedSecret = await cryptoService.encrypt(body.oauthConfig.clientSecretEncrypted); + + provider = await AuthProvider.createOAuthProvider({ + name: body.name, + displayName: body.displayName, + oauthConfig: { + ...body.oauthConfig, + clientSecretEncrypted: encryptedSecret, + }, + attributeMapping: body.attributeMapping, + provisioning: body.provisioning, + createdById: ctx.actor!.userId, + }); + } else if (body.type === 'ldap' && body.ldapConfig) { + // Encrypt bind password + const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted); + + provider = await AuthProvider.createLdapProvider({ + name: body.name, + displayName: body.displayName, + ldapConfig: { + ...body.ldapConfig, + bindPasswordEncrypted: encryptedPassword, + }, + attributeMapping: body.attributeMapping, + provisioning: body.provisioning, + createdById: ctx.actor!.userId, + }); + } else { + return { + status: 400, + body: { error: 'Invalid provider type' }, + }; + } + + // Audit log + await AuditService.withContext({ + actorId: ctx.actor!.userId, + actorType: 'user', + actorIp: ctx.ip, + }).log('ORGANIZATION_CREATED', 'system', { + resourceId: provider.id, + success: true, + metadata: { + action: 'auth_provider_created', + providerName: provider.name, + providerType: provider.type, + }, + }); + + return { + status: 201, + body: provider.toAdminInfo(), + }; + } catch (error) { + console.error('[AdminAuthApi] Create provider error:', error); + return { + status: 500, + body: { error: 'Failed to create provider' }, + }; + } + } + + /** + * GET /api/v1/admin/auth/providers/:id + * Get a specific authentication provider + */ + public async getProvider(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const { id } = ctx.params; + const provider = await AuthProvider.findById(id); + + if (!provider) { + return { + status: 404, + body: { error: 'Provider not found' }, + }; + } + + return { + status: 200, + body: provider.toAdminInfo(), + }; + } catch (error) { + console.error('[AdminAuthApi] Get provider error:', error); + return { + status: 500, + body: { error: 'Failed to get provider' }, + }; + } + } + + /** + * PUT /api/v1/admin/auth/providers/:id + * Update an authentication provider + */ + public async updateProvider(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const { id } = ctx.params; + const provider = await AuthProvider.findById(id); + + if (!provider) { + return { + status: 404, + body: { error: 'Provider not found' }, + }; + } + + const body = (await ctx.request.json()) as IUpdateAuthProviderDto; + + // Update basic fields + if (body.displayName !== undefined) provider.displayName = body.displayName; + if (body.status !== undefined) provider.status = body.status; + if (body.priority !== undefined) provider.priority = body.priority; + + // Update OAuth config + if (body.oauthConfig && provider.oauthConfig) { + const newOAuthConfig = { ...provider.oauthConfig, ...body.oauthConfig }; + + // Encrypt new client secret if provided and not already encrypted + if ( + body.oauthConfig.clientSecretEncrypted && + !cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted) + ) { + newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt( + body.oauthConfig.clientSecretEncrypted + ); + } + + provider.oauthConfig = newOAuthConfig; + } + + // Update LDAP config + if (body.ldapConfig && provider.ldapConfig) { + const newLdapConfig = { ...provider.ldapConfig, ...body.ldapConfig }; + + // Encrypt new bind password if provided and not already encrypted + if ( + body.ldapConfig.bindPasswordEncrypted && + !cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted) + ) { + newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt( + body.ldapConfig.bindPasswordEncrypted + ); + } + + provider.ldapConfig = newLdapConfig; + } + + // Update attribute mapping + if (body.attributeMapping) { + provider.attributeMapping = { ...provider.attributeMapping, ...body.attributeMapping }; + } + + // Update provisioning settings + if (body.provisioning) { + provider.provisioning = { ...provider.provisioning, ...body.provisioning }; + } + + await provider.save(); + + // Audit log + await AuditService.withContext({ + actorId: ctx.actor!.userId, + actorType: 'user', + actorIp: ctx.ip, + }).log('ORGANIZATION_UPDATED', 'system', { + resourceId: provider.id, + success: true, + metadata: { + action: 'auth_provider_updated', + providerName: provider.name, + }, + }); + + return { + status: 200, + body: provider.toAdminInfo(), + }; + } catch (error) { + console.error('[AdminAuthApi] Update provider error:', error); + return { + status: 500, + body: { error: 'Failed to update provider' }, + }; + } + } + + /** + * DELETE /api/v1/admin/auth/providers/:id + * Delete (or disable) an authentication provider + */ + public async deleteProvider(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const { id } = ctx.params; + const provider = await AuthProvider.findById(id); + + if (!provider) { + return { + status: 404, + body: { error: 'Provider not found' }, + }; + } + + // For now, just disable the provider instead of deleting + // This preserves audit history and linked identities + provider.status = 'disabled'; + await provider.save(); + + // Audit log + await AuditService.withContext({ + actorId: ctx.actor!.userId, + actorType: 'user', + actorIp: ctx.ip, + }).log('ORGANIZATION_DELETED', 'system', { + resourceId: provider.id, + success: true, + metadata: { + action: 'auth_provider_disabled', + providerName: provider.name, + }, + }); + + return { + status: 200, + body: { message: 'Provider disabled' }, + }; + } catch (error) { + console.error('[AdminAuthApi] Delete provider error:', error); + return { + status: 500, + body: { error: 'Failed to delete provider' }, + }; + } + } + + /** + * POST /api/v1/admin/auth/providers/:id/test + * Test provider connection + */ + public async testProvider(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const { id } = ctx.params; + const result = await externalAuthService.testConnection(id); + + // Audit log + await AuditService.withContext({ + actorId: ctx.actor!.userId, + actorType: 'user', + actorIp: ctx.ip, + }).log('ORGANIZATION_UPDATED', 'system', { + resourceId: id, + success: result.success, + metadata: { + action: 'auth_provider_tested', + result: result.success ? 'success' : 'failure', + latencyMs: result.latencyMs, + error: result.error, + }, + }); + + return { + status: 200, + body: result, + }; + } catch (error) { + console.error('[AdminAuthApi] Test provider error:', error); + return { + status: 500, + body: { error: 'Failed to test provider' }, + }; + } + } + + /** + * GET /api/v1/admin/auth/settings + * Get platform settings + */ + public async getSettings(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const settings = await PlatformSettings.get(); + return { + status: 200, + body: { + id: settings.id, + auth: settings.auth, + updatedAt: settings.updatedAt, + updatedById: settings.updatedById, + }, + }; + } catch (error) { + console.error('[AdminAuthApi] Get settings error:', error); + return { + status: 500, + body: { error: 'Failed to get settings' }, + }; + } + } + + /** + * PUT /api/v1/admin/auth/settings + * Update platform settings + */ + public async updateSettings(ctx: IApiContext): Promise { + const authError = this.requirePlatformAdmin(ctx); + if (authError) return authError; + + try { + const body = await ctx.request.json(); + const settings = await PlatformSettings.get(); + + if (body.auth) { + await settings.updateAuthSettings(body.auth, ctx.actor!.userId); + } + + // Audit log + await AuditService.withContext({ + actorId: ctx.actor!.userId, + actorType: 'user', + actorIp: ctx.ip, + }).log('ORGANIZATION_UPDATED', 'system', { + resourceId: 'platform-settings', + success: true, + metadata: { + action: 'platform_settings_updated', + }, + }); + + return { + status: 200, + body: { + id: settings.id, + auth: settings.auth, + updatedAt: settings.updatedAt, + updatedById: settings.updatedById, + }, + }; + } catch (error) { + console.error('[AdminAuthApi] Update settings error:', error); + return { + status: 500, + body: { error: 'Failed to update settings' }, + }; + } + } +} diff --git a/ts/api/handlers/oauth.api.ts b/ts/api/handlers/oauth.api.ts new file mode 100644 index 0000000..ad171fb --- /dev/null +++ b/ts/api/handlers/oauth.api.ts @@ -0,0 +1,188 @@ +/** + * OAuth API handlers + * Public endpoints for OAuth/OIDC and LDAP authentication flows + */ + +import type { IApiContext, IApiResponse } from '../router.ts'; +import { AuthProvider, PlatformSettings } from '../../models/index.ts'; +import { externalAuthService } from '../../services/external.auth.service.ts'; + +export class OAuthApi { + /** + * GET /api/v1/auth/providers + * List active authentication providers (public info only) + */ + public async listProviders(ctx: IApiContext): Promise { + try { + const settings = await PlatformSettings.get(); + const providers = await AuthProvider.getActiveProviders(); + + return { + status: 200, + body: { + providers: providers.map((p) => p.toPublicInfo()), + localAuthEnabled: settings.auth.localAuthEnabled, + defaultProviderId: settings.auth.defaultProviderId, + }, + }; + } catch (error) { + console.error('[OAuthApi] List providers error:', error); + return { + status: 500, + body: { error: 'Failed to list providers' }, + }; + } + } + + /** + * GET /api/v1/auth/oauth/:id/authorize + * Initiate OAuth flow - redirects to provider + */ + public async authorize(ctx: IApiContext): Promise { + try { + const { id } = ctx.params; + const returnUrl = ctx.url.searchParams.get('returnUrl') || undefined; + + const { authUrl } = await externalAuthService.initiateOAuth(id, returnUrl); + + // Return redirect response + return { + status: 302, + headers: { Location: authUrl }, + }; + } catch (error) { + console.error('[OAuthApi] Authorize error:', error); + const errorMessage = error instanceof Error ? error.message : 'Authorization failed'; + return { + status: 302, + headers: { + Location: `/login?error=${encodeURIComponent(errorMessage)}`, + }, + }; + } + } + + /** + * GET /api/v1/auth/oauth/:id/callback + * Handle OAuth callback from provider + */ + public async callback(ctx: IApiContext): Promise { + try { + const code = ctx.url.searchParams.get('code'); + const state = ctx.url.searchParams.get('state'); + const error = ctx.url.searchParams.get('error'); + const errorDescription = ctx.url.searchParams.get('error_description'); + + if (error) { + return { + status: 302, + headers: { + Location: `/login?error=${encodeURIComponent(errorDescription || error)}`, + }, + }; + } + + if (!code || !state) { + return { + status: 302, + headers: { + Location: '/login?error=missing_parameters', + }, + }; + } + + const result = await externalAuthService.handleOAuthCallback( + { code, state }, + { ipAddress: ctx.ip, userAgent: ctx.userAgent } + ); + + if (!result.success) { + return { + status: 302, + headers: { + Location: `/login?error=${encodeURIComponent(result.errorCode || 'auth_failed')}`, + }, + }; + } + + // Redirect to OAuth callback page with tokens + const params = new URLSearchParams({ + accessToken: result.accessToken!, + refreshToken: result.refreshToken!, + sessionId: result.sessionId!, + }); + + return { + status: 302, + headers: { + Location: `/oauth-callback?${params.toString()}`, + }, + }; + } catch (error) { + console.error('[OAuthApi] Callback error:', error); + const errorMessage = error instanceof Error ? error.message : 'Callback failed'; + return { + status: 302, + headers: { + Location: `/login?error=${encodeURIComponent(errorMessage)}`, + }, + }; + } + } + + /** + * POST /api/v1/auth/ldap/:id/login + * LDAP authentication with username/password + */ + public async ldapLogin(ctx: IApiContext): Promise { + try { + const { id } = ctx.params; + const body = await ctx.request.json(); + const { username, password } = body; + + if (!username || !password) { + return { + status: 400, + body: { error: 'Username and password are required' }, + }; + } + + const result = await externalAuthService.authenticateLdap(id, username, password, { + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + if (!result.success) { + return { + status: 401, + body: { + error: result.errorMessage, + code: result.errorCode, + }, + }; + } + + return { + status: 200, + body: { + user: { + id: result.user!.id, + email: result.user!.email, + username: result.user!.username, + displayName: result.user!.displayName, + isSystemAdmin: result.user!.isSystemAdmin, + }, + accessToken: result.accessToken, + refreshToken: result.refreshToken, + sessionId: result.sessionId, + }, + }; + } catch (error) { + console.error('[OAuthApi] LDAP login error:', error); + return { + status: 500, + body: { error: 'LDAP login failed' }, + }; + } + } +} diff --git a/ts/api/router.ts b/ts/api/router.ts index f3e3107..3872f9b 100644 --- a/ts/api/router.ts +++ b/ts/api/router.ts @@ -16,6 +16,8 @@ import { RepositoryApi } from './handlers/repository.api.ts'; import { PackageApi } from './handlers/package.api.ts'; import { TokenApi } from './handlers/token.api.ts'; import { AuditApi } from './handlers/audit.api.ts'; +import { AdminAuthApi } from './handlers/admin.auth.api.ts'; +import { OAuthApi } from './handlers/oauth.api.ts'; export interface IApiContext { request: Request; @@ -57,6 +59,8 @@ export class ApiRouter { private packageApi: PackageApi; private tokenApi: TokenApi; private auditApi: AuditApi; + private adminAuthApi: AdminAuthApi; + private oauthApi: OAuthApi; constructor() { this.authService = new AuthService(); @@ -71,6 +75,8 @@ export class ApiRouter { this.packageApi = new PackageApi(this.permissionService); this.tokenApi = new TokenApi(this.tokenService); this.auditApi = new AuditApi(this.permissionService); + this.adminAuthApi = new AdminAuthApi(); + this.oauthApi = new OAuthApi(); this.registerRoutes(); } @@ -124,6 +130,22 @@ export class ApiRouter { // Audit routes this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx)); + + // OAuth/External auth routes (public) + this.addRoute('GET', '/api/v1/auth/providers', (ctx) => this.oauthApi.listProviders(ctx)); + this.addRoute('GET', '/api/v1/auth/oauth/:id/authorize', (ctx) => this.oauthApi.authorize(ctx)); + this.addRoute('GET', '/api/v1/auth/oauth/:id/callback', (ctx) => this.oauthApi.callback(ctx)); + this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx)); + + // Admin auth routes (platform admin only) + this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx)); + this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx)); + this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx)); + this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx)); + this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx)); + this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx)); + this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx)); + this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx)); } /** diff --git a/ts/interfaces/auth.interfaces.ts b/ts/interfaces/auth.interfaces.ts index 5fff6a9..2a0a563 100644 --- a/ts/interfaces/auth.interfaces.ts +++ b/ts/interfaces/auth.interfaces.ts @@ -286,3 +286,140 @@ export interface ICreateTokenDto { scopes: ITokenScope[]; expiresAt?: Date; } + +// ============================================================================= +// External Authentication Types +// ============================================================================= + +export type TAuthProviderType = 'oidc' | 'ldap'; +export type TAuthProviderStatus = 'active' | 'disabled' | 'testing'; + +export interface IOAuthConfig { + clientId: string; + clientSecretEncrypted: string; // AES-256-GCM encrypted + issuer: string; // OIDC issuer URL (used for discovery) + authorizationUrl?: string; // Override discovery + tokenUrl?: string; // Override discovery + userInfoUrl?: string; // Override discovery + scopes: string[]; + callbackUrl: string; +} + +export interface ILdapConfig { + serverUrl: string; // ldap:// or ldaps:// + bindDn: string; + bindPasswordEncrypted: string; // AES-256-GCM encrypted + baseDn: string; + userSearchFilter: string; // e.g., "(uid={{username}})" or "(sAMAccountName={{username}})" + tlsEnabled: boolean; + tlsCaCert?: string; +} + +export interface IAttributeMapping { + email: string; + username: string; + displayName: string; + avatarUrl?: string; + groups?: string; +} + +export interface IProvisioningSettings { + jitEnabled: boolean; // Create user on first login + autoLinkByEmail: boolean; // Link to existing user by email match + allowedEmailDomains?: string[]; // Restrict to specific domains +} + +export interface IAuthProvider { + id: string; + name: string; + displayName: string; + type: TAuthProviderType; + status: TAuthProviderStatus; + priority: number; + oauthConfig?: IOAuthConfig; + ldapConfig?: ILdapConfig; + attributeMapping: IAttributeMapping; + provisioning: IProvisioningSettings; + createdAt: Date; + updatedAt: Date; + createdById: string; + lastTestedAt?: Date; + lastTestResult?: 'success' | 'failure'; + lastTestError?: string; +} + +export interface IExternalIdentity { + id: string; + userId: string; + providerId: string; + externalId: string; + externalEmail?: string; + externalUsername?: string; + rawAttributes?: Record; + lastLoginAt?: Date; + createdAt: Date; +} + +export interface IPlatformAuthSettings { + localAuthEnabled: boolean; + allowUserRegistration: boolean; + sessionDurationMinutes: number; + defaultProviderId?: string; +} + +export interface IPlatformSettings { + id: string; + auth: IPlatformAuthSettings; + updatedAt: Date; + updatedById?: string; +} + +// External auth flow types +export interface IExternalUserInfo { + externalId: string; + email: string; + username?: string; + displayName?: string; + avatarUrl?: string; + groups?: string[]; + rawAttributes: Record; +} + +export interface IConnectionTestResult { + success: boolean; + latencyMs: number; + serverInfo?: Record; + error?: string; +} + +export interface IExternalAuthResult { + success: boolean; + user?: IUser; + accessToken?: string; + refreshToken?: string; + sessionId?: string; + isNewUser?: boolean; + errorCode?: string; + errorMessage?: string; +} + +// Admin DTOs +export interface ICreateAuthProviderDto { + name: string; + displayName: string; + type: TAuthProviderType; + oauthConfig?: IOAuthConfig; + ldapConfig?: ILdapConfig; + attributeMapping?: IAttributeMapping; + provisioning?: IProvisioningSettings; +} + +export interface IUpdateAuthProviderDto { + displayName?: string; + status?: TAuthProviderStatus; + priority?: number; + oauthConfig?: Partial; + ldapConfig?: Partial; + attributeMapping?: Partial; + provisioning?: Partial; +} diff --git a/ts/models/auth.provider.ts b/ts/models/auth.provider.ts new file mode 100644 index 0000000..184dc86 --- /dev/null +++ b/ts/models/auth.provider.ts @@ -0,0 +1,252 @@ +/** + * Authentication Provider model for Stack.Gallery Registry + * Stores OAuth/OIDC and LDAP provider configurations + */ + +import * as plugins from '../plugins.ts'; +import type { + IAuthProvider, + TAuthProviderType, + TAuthProviderStatus, + IOAuthConfig, + ILdapConfig, + IAttributeMapping, + IProvisioningSettings, +} from '../interfaces/auth.interfaces.ts'; +import { db } from './db.ts'; + +const DEFAULT_ATTRIBUTE_MAPPING: IAttributeMapping = { + email: 'email', + username: 'preferred_username', + displayName: 'name', +}; + +const DEFAULT_PROVISIONING: IProvisioningSettings = { + jitEnabled: true, + autoLinkByEmail: true, +}; + +@plugins.smartdata.Collection(() => db) +export class AuthProvider + extends plugins.smartdata.SmartDataDbDoc + implements IAuthProvider +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index({ unique: true }) + public name: string = ''; // URL-safe slug identifier + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + public displayName: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public type: TAuthProviderType = 'oidc'; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public status: TAuthProviderStatus = 'disabled'; + + @plugins.smartdata.svDb() + public priority: number = 100; // Lower = shown first in UI + + // Type-specific config (only one should be populated based on type) + @plugins.smartdata.svDb() + public oauthConfig?: IOAuthConfig; + + @plugins.smartdata.svDb() + public ldapConfig?: ILdapConfig; + + @plugins.smartdata.svDb() + public attributeMapping: IAttributeMapping = DEFAULT_ATTRIBUTE_MAPPING; + + @plugins.smartdata.svDb() + public provisioning: IProvisioningSettings = DEFAULT_PROVISIONING; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public updatedAt: Date = new Date(); + + @plugins.smartdata.svDb() + public createdById: string = ''; + + // Connection test tracking + @plugins.smartdata.svDb() + public lastTestedAt?: Date; + + @plugins.smartdata.svDb() + public lastTestResult?: 'success' | 'failure'; + + @plugins.smartdata.svDb() + public lastTestError?: string; + + /** + * Find provider by ID + */ + public static async findById(id: string): Promise { + return await AuthProvider.getInstance({ id }); + } + + /** + * Find provider by name (slug) + */ + public static async findByName(name: string): Promise { + return await AuthProvider.getInstance({ name: name.toLowerCase() }); + } + + /** + * Get all active providers (for login page) + */ + public static async getActiveProviders(): Promise { + const providers = await AuthProvider.getInstances({ status: 'active' }); + return providers.sort((a, b) => a.priority - b.priority); + } + + /** + * Get all providers (for admin) + */ + public static async getAllProviders(): Promise { + const providers = await AuthProvider.getInstances({}); + return providers.sort((a, b) => a.priority - b.priority); + } + + /** + * Create a new OAuth/OIDC provider + */ + public static async createOAuthProvider(data: { + name: string; + displayName: string; + oauthConfig: IOAuthConfig; + attributeMapping?: IAttributeMapping; + provisioning?: IProvisioningSettings; + createdById: string; + }): Promise { + const provider = new AuthProvider(); + provider.id = await AuthProvider.getNewId(); + provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + provider.displayName = data.displayName; + provider.type = 'oidc'; + provider.status = 'disabled'; + provider.oauthConfig = data.oauthConfig; + provider.attributeMapping = data.attributeMapping || DEFAULT_ATTRIBUTE_MAPPING; + provider.provisioning = data.provisioning || DEFAULT_PROVISIONING; + provider.createdById = data.createdById; + provider.createdAt = new Date(); + provider.updatedAt = new Date(); + await provider.save(); + return provider; + } + + /** + * Create a new LDAP provider + */ + public static async createLdapProvider(data: { + name: string; + displayName: string; + ldapConfig: ILdapConfig; + attributeMapping?: IAttributeMapping; + provisioning?: IProvisioningSettings; + createdById: string; + }): Promise { + const provider = new AuthProvider(); + provider.id = await AuthProvider.getNewId(); + provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + provider.displayName = data.displayName; + provider.type = 'ldap'; + provider.status = 'disabled'; + provider.ldapConfig = data.ldapConfig; + provider.attributeMapping = data.attributeMapping || { + email: 'mail', + username: 'uid', + displayName: 'displayName', + }; + provider.provisioning = data.provisioning || DEFAULT_PROVISIONING; + provider.createdById = data.createdById; + provider.createdAt = new Date(); + provider.updatedAt = new Date(); + await provider.save(); + return provider; + } + + /** + * Update connection test result + */ + public async updateTestResult(success: boolean, error?: string): Promise { + this.lastTestedAt = new Date(); + this.lastTestResult = success ? 'success' : 'failure'; + this.lastTestError = error; + await this.save(); + } + + /** + * Lifecycle hook: Update timestamps before save + */ + public async beforeSave(): Promise { + this.updatedAt = new Date(); + if (!this.id) { + this.id = await AuthProvider.getNewId(); + } + } + + /** + * Get public info (for login page - no secrets) + */ + public toPublicInfo(): { + id: string; + name: string; + displayName: string; + type: TAuthProviderType; + } { + return { + id: this.id, + name: this.name, + displayName: this.displayName, + type: this.type, + }; + } + + /** + * Get admin info (secrets masked) + */ + public toAdminInfo(): Record { + const info: Record = { + id: this.id, + name: this.name, + displayName: this.displayName, + type: this.type, + status: this.status, + priority: this.priority, + attributeMapping: this.attributeMapping, + provisioning: this.provisioning, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + createdById: this.createdById, + lastTestedAt: this.lastTestedAt, + lastTestResult: this.lastTestResult, + lastTestError: this.lastTestError, + }; + + // Mask secrets in config + if (this.oauthConfig) { + info.oauthConfig = { + ...this.oauthConfig, + clientSecretEncrypted: this.oauthConfig.clientSecretEncrypted ? '********' : undefined, + }; + } + + if (this.ldapConfig) { + info.ldapConfig = { + ...this.ldapConfig, + bindPasswordEncrypted: this.ldapConfig.bindPasswordEncrypted ? '********' : undefined, + }; + } + + return info; + } +} diff --git a/ts/models/external.identity.ts b/ts/models/external.identity.ts new file mode 100644 index 0000000..e40274f --- /dev/null +++ b/ts/models/external.identity.ts @@ -0,0 +1,142 @@ +/** + * External Identity model for Stack.Gallery Registry + * Links users to external authentication provider accounts + */ + +import * as plugins from '../plugins.ts'; +import type { IExternalIdentity } from '../interfaces/auth.interfaces.ts'; +import { db } from './db.ts'; + +@plugins.smartdata.Collection(() => db) +export class ExternalIdentity + extends plugins.smartdata.SmartDataDbDoc + implements IExternalIdentity +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public userId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public providerId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public externalId: string = ''; // ID from the external provider + + @plugins.smartdata.svDb() + public externalEmail?: string; + + @plugins.smartdata.svDb() + public externalUsername?: string; + + @plugins.smartdata.svDb() + public rawAttributes?: Record; + + @plugins.smartdata.svDb() + public lastLoginAt?: Date; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + /** + * Find by ID + */ + public static async findById(id: string): Promise { + return await ExternalIdentity.getInstance({ id }); + } + + /** + * Find by provider and external ID (unique combination) + */ + public static async findByExternalId( + providerId: string, + externalId: string + ): Promise { + return await ExternalIdentity.getInstance({ providerId, externalId }); + } + + /** + * Find all identities for a user + */ + public static async findByUserId(userId: string): Promise { + return await ExternalIdentity.getInstances({ userId }); + } + + /** + * Find identity by user and provider + */ + public static async findByUserAndProvider( + userId: string, + providerId: string + ): Promise { + return await ExternalIdentity.getInstance({ userId, providerId }); + } + + /** + * Create a new external identity link + */ + public static async createIdentity(data: { + userId: string; + providerId: string; + externalId: string; + externalEmail?: string; + externalUsername?: string; + rawAttributes?: Record; + }): Promise { + // Check if this external ID is already linked + const existing = await ExternalIdentity.findByExternalId(data.providerId, data.externalId); + if (existing) { + throw new Error('This external account is already linked to a user'); + } + + const identity = new ExternalIdentity(); + identity.id = await ExternalIdentity.getNewId(); + identity.userId = data.userId; + identity.providerId = data.providerId; + identity.externalId = data.externalId; + identity.externalEmail = data.externalEmail; + identity.externalUsername = data.externalUsername; + identity.rawAttributes = data.rawAttributes; + identity.lastLoginAt = new Date(); + identity.createdAt = new Date(); + await identity.save(); + return identity; + } + + /** + * Update last login time + */ + public async updateLastLogin(): Promise { + this.lastLoginAt = new Date(); + await this.save(); + } + + /** + * Update attributes from provider + */ + public async updateAttributes(data: { + externalEmail?: string; + externalUsername?: string; + rawAttributes?: Record; + }): Promise { + if (data.externalEmail !== undefined) this.externalEmail = data.externalEmail; + if (data.externalUsername !== undefined) this.externalUsername = data.externalUsername; + if (data.rawAttributes !== undefined) this.rawAttributes = data.rawAttributes; + this.lastLoginAt = new Date(); + await this.save(); + } + + /** + * Lifecycle hook: Generate ID before save + */ + public async beforeSave(): Promise { + if (!this.id) { + this.id = await ExternalIdentity.getNewId(); + } + } +} diff --git a/ts/models/index.ts b/ts/models/index.ts index 7b7fda0..3639fdd 100644 --- a/ts/models/index.ts +++ b/ts/models/index.ts @@ -14,3 +14,8 @@ export { Package } from './package.ts'; export { ApiToken } from './apitoken.ts'; export { Session } from './session.ts'; export { AuditLog } from './auditlog.ts'; + +// External authentication models +export { AuthProvider } from './auth.provider.ts'; +export { ExternalIdentity } from './external.identity.ts'; +export { PlatformSettings } from './platform.settings.ts'; diff --git a/ts/models/platform.settings.ts b/ts/models/platform.settings.ts new file mode 100644 index 0000000..0e92f49 --- /dev/null +++ b/ts/models/platform.settings.ts @@ -0,0 +1,90 @@ +/** + * Platform Settings model for Stack.Gallery Registry + * Singleton model storing global platform configuration + */ + +import * as plugins from '../plugins.ts'; +import type { IPlatformSettings, IPlatformAuthSettings } from '../interfaces/auth.interfaces.ts'; +import { db } from './db.ts'; + +const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = { + localAuthEnabled: true, + allowUserRegistration: true, + sessionDurationMinutes: 10080, // 7 days +}; + +@plugins.smartdata.Collection(() => db) +export class PlatformSettings + extends plugins.smartdata.SmartDataDbDoc + implements IPlatformSettings +{ + @plugins.smartdata.unI() + public id: string = 'singleton'; + + @plugins.smartdata.svDb() + public auth: IPlatformAuthSettings = DEFAULT_AUTH_SETTINGS; + + @plugins.smartdata.svDb() + public updatedAt: Date = new Date(); + + @plugins.smartdata.svDb() + public updatedById?: string; + + /** + * Get the singleton settings instance (creates if not exists) + */ + public static async get(): Promise { + let settings = await PlatformSettings.getInstance({ id: 'singleton' }); + if (!settings) { + settings = new PlatformSettings(); + settings.id = 'singleton'; + settings.auth = DEFAULT_AUTH_SETTINGS; + settings.updatedAt = new Date(); + await settings.save(); + console.log('[PlatformSettings] Created default settings'); + } + return settings; + } + + /** + * Update auth settings + */ + public async updateAuthSettings( + settings: Partial, + updatedById?: string + ): Promise { + this.auth = { ...this.auth, ...settings }; + this.updatedAt = new Date(); + this.updatedById = updatedById; + await this.save(); + } + + /** + * Check if local auth is enabled + */ + public isLocalAuthEnabled(): boolean { + return this.auth.localAuthEnabled; + } + + /** + * Check if registration is allowed + */ + public isRegistrationAllowed(): boolean { + return this.auth.allowUserRegistration; + } + + /** + * Get default provider ID (for auto-redirect) + */ + public getDefaultProviderId(): string | undefined { + return this.auth.defaultProviderId; + } + + /** + * Lifecycle hook: Ensure singleton ID + */ + public async beforeSave(): Promise { + this.id = 'singleton'; + this.updatedAt = new Date(); + } +} diff --git a/ts/models/user.ts b/ts/models/user.ts index f826f66..f3ff575 100644 --- a/ts/models/user.ts +++ b/ts/models/user.ts @@ -67,6 +67,16 @@ export class User extends plugins.smartdata.SmartDataDbDoc implement @plugins.smartdata.svDb() public updatedAt: Date = new Date(); + // External authentication fields + @plugins.smartdata.svDb() + public externalIdentityIds: string[] = []; + + @plugins.smartdata.svDb() + public canUseLocalAuth: boolean = true; + + @plugins.smartdata.svDb() + public provisionedByProviderId?: string; // Provider that JIT-created this user + /** * Create a new user instance */ diff --git a/ts/services/auth/strategies/auth.strategy.interface.ts b/ts/services/auth/strategies/auth.strategy.interface.ts new file mode 100644 index 0000000..01860e5 --- /dev/null +++ b/ts/services/auth/strategies/auth.strategy.interface.ts @@ -0,0 +1,47 @@ +/** + * Authentication Strategy Interface + * Base interface for OAuth/OIDC and LDAP authentication strategies + */ + +import type { + IExternalUserInfo, + IConnectionTestResult, +} from '../../../interfaces/auth.interfaces.ts'; + +export interface IOAuthCallbackData { + code: string; + state: string; + error?: string; + errorDescription?: string; +} + +export interface IAuthStrategy { + /** + * Get the authorization URL for OAuth/OIDC flow + * @param state - CSRF state token + * @param nonce - Optional nonce for OIDC + * @returns Authorization URL to redirect user to + */ + getAuthorizationUrl?(state: string, nonce?: string): Promise; + + /** + * Handle OAuth/OIDC callback + * @param data - Callback data including code and state + * @returns External user info from the provider + */ + handleCallback?(data: IOAuthCallbackData): Promise; + + /** + * Authenticate with credentials (LDAP) + * @param username - Username + * @param password - Password + * @returns External user info if authentication succeeds + */ + authenticateCredentials?(username: string, password: string): Promise; + + /** + * Test connection to the provider + * @returns Connection test result + */ + testConnection(): Promise; +} diff --git a/ts/services/auth/strategies/index.ts b/ts/services/auth/strategies/index.ts new file mode 100644 index 0000000..4e732a6 --- /dev/null +++ b/ts/services/auth/strategies/index.ts @@ -0,0 +1,8 @@ +/** + * Auth Strategy exports + */ + +export type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts'; +export { OAuthStrategy } from './oauth.strategy.ts'; +export { LdapStrategy } from './ldap.strategy.ts'; +export { AuthStrategyFactory } from './strategy.factory.ts'; diff --git a/ts/services/auth/strategies/ldap.strategy.ts b/ts/services/auth/strategies/ldap.strategy.ts new file mode 100644 index 0000000..e27fefc --- /dev/null +++ b/ts/services/auth/strategies/ldap.strategy.ts @@ -0,0 +1,242 @@ +/** + * LDAP Authentication Strategy + * Handles LDAP/Active Directory authentication + * + * Note: This is a basic implementation. For production use with actual LDAP, + * you may need to integrate with a Deno-compatible LDAP library. + */ + +import type { AuthProvider } from '../../../models/auth.provider.ts'; +import type { CryptoService } from '../../crypto.service.ts'; +import type { + IExternalUserInfo, + IConnectionTestResult, +} from '../../../interfaces/auth.interfaces.ts'; +import type { IAuthStrategy } from './auth.strategy.interface.ts'; + +// LDAP entry type (simplified) +interface ILdapEntry { + dn: string; + [key: string]: unknown; +} + +export class LdapStrategy implements IAuthStrategy { + constructor( + private provider: AuthProvider, + private cryptoService: CryptoService + ) {} + + /** + * Authenticate user with LDAP credentials + */ + public async authenticateCredentials( + username: string, + password: string + ): Promise { + const config = this.provider.ldapConfig; + if (!config) { + throw new Error('LDAP config not found'); + } + + // Escape username to prevent LDAP injection + const escapedUsername = this.escapeLdap(username); + + // Build user search filter + const userFilter = config.userSearchFilter.replace('{{username}}', escapedUsername); + + // Decrypt bind password + const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted); + + // Perform LDAP authentication + // This is a placeholder - actual implementation would use an LDAP library + const userEntry = await this.ldapBind( + config.serverUrl, + config.bindDn, + bindPassword, + config.baseDn, + userFilter, + password + ); + + // Map LDAP attributes to user info + return this.mapAttributes(userEntry); + } + + /** + * Test LDAP connection + */ + public async testConnection(): Promise { + const start = Date.now(); + const config = this.provider.ldapConfig; + + if (!config) { + return { + success: false, + latencyMs: Date.now() - start, + error: 'LDAP config not found', + }; + } + + try { + // Decrypt bind password + const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted); + + // Test connection by binding with service account + await this.testLdapConnection( + config.serverUrl, + config.bindDn, + bindPassword, + config.baseDn + ); + + return { + success: true, + latencyMs: Date.now() - start, + serverInfo: { + serverUrl: config.serverUrl, + baseDn: config.baseDn, + tlsEnabled: config.tlsEnabled, + }, + }; + } catch (error) { + return { + success: false, + latencyMs: Date.now() - start, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Escape special LDAP characters to prevent injection + */ + private escapeLdap(value: string): string { + return value + .replace(/\\/g, '\\5c') + .replace(/\*/g, '\\2a') + .replace(/\(/g, '\\28') + .replace(/\)/g, '\\29') + .replace(/\x00/g, '\\00'); + } + + /** + * Perform LDAP bind and search + * This is a placeholder implementation - actual LDAP would require a library + */ + private async ldapBind( + serverUrl: string, + bindDn: string, + bindPassword: string, + baseDn: string, + userFilter: string, + userPassword: string + ): Promise { + // In a real implementation, this would: + // 1. Connect to LDAP server + // 2. Bind with service account (bindDn/bindPassword) + // 3. Search for user with userFilter + // 4. Re-bind with user's DN and password to verify + // 5. Return user entry if successful + + // For now, we throw an error indicating LDAP needs to be configured + // This allows the structure to be in place while the actual LDAP library + // integration can be done separately + + console.log('[LdapStrategy] LDAP auth attempt:', { + serverUrl, + baseDn, + userFilter, + }); + + throw new Error( + 'LDAP authentication is not yet fully implemented. ' + + 'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).' + ); + } + + /** + * Test LDAP connection + */ + private async testLdapConnection( + serverUrl: string, + bindDn: string, + bindPassword: string, + baseDn: string + ): Promise { + // Similar to ldapBind, this is a placeholder + // Would connect and bind with service account to verify connectivity + + console.log('[LdapStrategy] Testing LDAP connection:', { + serverUrl, + bindDn, + baseDn, + }); + + // For now, check if server URL is valid + if (!serverUrl.startsWith('ldap://') && !serverUrl.startsWith('ldaps://')) { + throw new Error('Invalid LDAP server URL. Must start with ldap:// or ldaps://'); + } + + // In a real implementation, we would actually connect here + // For now, we just validate the configuration + if (!bindDn || !bindPassword || !baseDn) { + throw new Error('Missing required LDAP configuration'); + } + + // Return success for configuration validation + // Actual connectivity test would happen with LDAP library + console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)'); + } + + /** + * Map LDAP attributes to standard user info + */ + private mapAttributes(entry: ILdapEntry): IExternalUserInfo { + const mapping = this.provider.attributeMapping; + + // Get external ID (typically uid or sAMAccountName) + const externalId = String(entry[mapping.username] || entry.dn); + + // Get email + const email = entry[mapping.email]; + if (!email || typeof email !== 'string') { + throw new Error('Email not found in LDAP entry'); + } + + return { + externalId, + email, + username: entry[mapping.username] + ? String(entry[mapping.username]) + : undefined, + displayName: entry[mapping.displayName] + ? String(entry[mapping.displayName]) + : undefined, + groups: mapping.groups + ? this.parseGroups(entry[mapping.groups]) + : undefined, + rawAttributes: entry as Record, + }; + } + + /** + * Parse LDAP group membership + */ + private parseGroups(memberOf: unknown): string[] { + if (!memberOf) return []; + + if (Array.isArray(memberOf)) { + return memberOf.map((dn) => this.extractCnFromDn(String(dn))); + } + + return [this.extractCnFromDn(String(memberOf))]; + } + + /** + * Extract CN (Common Name) from a DN (Distinguished Name) + */ + private extractCnFromDn(dn: string): string { + const match = dn.match(/^CN=([^,]+)/i); + return match ? match[1] : dn; + } +} diff --git a/ts/services/auth/strategies/oauth.strategy.ts b/ts/services/auth/strategies/oauth.strategy.ts new file mode 100644 index 0000000..fb17448 --- /dev/null +++ b/ts/services/auth/strategies/oauth.strategy.ts @@ -0,0 +1,263 @@ +/** + * OAuth/OIDC Authentication Strategy + * Handles OAuth 2.0 and OpenID Connect flows + */ + +import type { AuthProvider } from '../../../models/auth.provider.ts'; +import type { CryptoService } from '../../crypto.service.ts'; +import type { + IExternalUserInfo, + IConnectionTestResult, +} from '../../../interfaces/auth.interfaces.ts'; +import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts'; + +interface ITokenResponse { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + id_token?: string; + scope?: string; +} + +interface IOIDCDiscovery { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + jwks_uri?: string; + scopes_supported?: string[]; +} + +export class OAuthStrategy implements IAuthStrategy { + private discoveryCache: IOIDCDiscovery | null = null; + + constructor( + private provider: AuthProvider, + private cryptoService: CryptoService + ) {} + + /** + * Get the authorization URL for initiating OAuth flow + */ + public async getAuthorizationUrl(state: string, nonce?: string): Promise { + const config = this.provider.oauthConfig; + if (!config) { + throw new Error('OAuth config not found'); + } + + // Get authorization URL from config or discovery + let authorizationUrl = config.authorizationUrl; + if (!authorizationUrl) { + const discovery = await this.getDiscovery(); + authorizationUrl = discovery.authorization_endpoint; + } + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: config.callbackUrl, + response_type: 'code', + scope: config.scopes.join(' '), + state, + }); + + // Add nonce for OIDC + if (nonce) { + params.set('nonce', nonce); + } + + return `${authorizationUrl}?${params.toString()}`; + } + + /** + * Handle OAuth callback - exchange code for tokens and get user info + */ + public async handleCallback(data: IOAuthCallbackData): Promise { + if (data.error) { + throw new Error(`OAuth error: ${data.error} - ${data.errorDescription || ''}`); + } + + const config = this.provider.oauthConfig; + if (!config) { + throw new Error('OAuth config not found'); + } + + // Exchange code for tokens + const tokens = await this.exchangeCodeForTokens(data.code); + + // Get user info + const userInfo = await this.fetchUserInfo(tokens.access_token); + + // Map attributes according to provider config + return this.mapAttributes(userInfo); + } + + /** + * Test connection by fetching OIDC discovery document + */ + public async testConnection(): Promise { + const start = Date.now(); + const config = this.provider.oauthConfig; + + if (!config) { + return { + success: false, + latencyMs: Date.now() - start, + error: 'OAuth config not found', + }; + } + + try { + const discovery = await this.getDiscovery(); + + return { + success: true, + latencyMs: Date.now() - start, + serverInfo: { + issuer: discovery.issuer, + scopes_supported: discovery.scopes_supported, + has_userinfo: !!discovery.userinfo_endpoint, + }, + }; + } catch (error) { + return { + success: false, + latencyMs: Date.now() - start, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Exchange authorization code for tokens + */ + private async exchangeCodeForTokens(code: string): Promise { + const config = this.provider.oauthConfig!; + + // Get token URL from config or discovery + let tokenUrl = config.tokenUrl; + if (!tokenUrl) { + const discovery = await this.getDiscovery(); + tokenUrl = discovery.token_endpoint; + } + + // Decrypt client secret + const clientSecret = await this.cryptoService.decrypt(config.clientSecretEncrypted); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.callbackUrl, + client_id: config.clientId, + client_secret: clientSecret, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${response.status} - ${errorText}`); + } + + return await response.json(); + } + + /** + * Fetch user info from the provider + */ + private async fetchUserInfo(accessToken: string): Promise> { + const config = this.provider.oauthConfig!; + + // Get userinfo URL from config or discovery + let userInfoUrl = config.userInfoUrl; + if (!userInfoUrl) { + const discovery = await this.getDiscovery(); + userInfoUrl = discovery.userinfo_endpoint; + } + + if (!userInfoUrl) { + throw new Error('UserInfo endpoint not found'); + } + + const response = await fetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`UserInfo fetch failed: ${response.status} - ${errorText}`); + } + + return await response.json(); + } + + /** + * Get OIDC discovery document + */ + private async getDiscovery(): Promise { + if (this.discoveryCache) { + return this.discoveryCache; + } + + const config = this.provider.oauthConfig!; + const discoveryUrl = `${config.issuer}/.well-known/openid-configuration`; + + const response = await fetch(discoveryUrl, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`OIDC discovery failed: ${response.status}`); + } + + this.discoveryCache = await response.json(); + return this.discoveryCache!; + } + + /** + * Map provider attributes to standard user info + */ + private mapAttributes(rawInfo: Record): IExternalUserInfo { + const mapping = this.provider.attributeMapping; + + // Get external ID (sub for OIDC, or id for OAuth2) + const externalId = String(rawInfo.sub || rawInfo.id || ''); + if (!externalId) { + throw new Error('External ID not found in user info'); + } + + // Get email + const email = rawInfo[mapping.email]; + if (!email || typeof email !== 'string') { + throw new Error('Email not found in user info'); + } + + return { + externalId, + email, + username: rawInfo[mapping.username] + ? String(rawInfo[mapping.username]) + : undefined, + displayName: rawInfo[mapping.displayName] + ? String(rawInfo[mapping.displayName]) + : undefined, + avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl] + ? String(rawInfo[mapping.avatarUrl]) + : (rawInfo.picture ? String(rawInfo.picture) : undefined), + groups: mapping.groups && rawInfo[mapping.groups] + ? (Array.isArray(rawInfo[mapping.groups]) + ? (rawInfo[mapping.groups] as string[]) + : [String(rawInfo[mapping.groups])]) + : undefined, + rawAttributes: rawInfo, + }; + } +} diff --git a/ts/services/auth/strategies/strategy.factory.ts b/ts/services/auth/strategies/strategy.factory.ts new file mode 100644 index 0000000..2cecbf9 --- /dev/null +++ b/ts/services/auth/strategies/strategy.factory.ts @@ -0,0 +1,28 @@ +/** + * Auth Strategy Factory + * Creates the appropriate authentication strategy based on provider type + */ + +import type { AuthProvider } from '../../../models/auth.provider.ts'; +import type { CryptoService } from '../../crypto.service.ts'; +import type { IAuthStrategy } from './auth.strategy.interface.ts'; +import { OAuthStrategy } from './oauth.strategy.ts'; +import { LdapStrategy } from './ldap.strategy.ts'; + +export class AuthStrategyFactory { + constructor(private cryptoService: CryptoService) {} + + /** + * Create the appropriate strategy for a provider + */ + public create(provider: AuthProvider): IAuthStrategy { + switch (provider.type) { + case 'oidc': + return new OAuthStrategy(provider, this.cryptoService); + case 'ldap': + return new LdapStrategy(provider, this.cryptoService); + default: + throw new Error(`Unsupported provider type: ${provider.type}`); + } + } +} diff --git a/ts/services/crypto.service.ts b/ts/services/crypto.service.ts new file mode 100644 index 0000000..0b6c719 --- /dev/null +++ b/ts/services/crypto.service.ts @@ -0,0 +1,178 @@ +/** + * Crypto Service for Stack.Gallery Registry + * Handles AES-256-GCM encryption/decryption of secrets + */ + +export class CryptoService { + private masterKey: CryptoKey | null = null; + private initialized = false; + + /** + * Initialize the crypto service with the master key + * The key should be a 64-character hex string (32 bytes = 256 bits) + */ + public async initialize(): Promise { + if (this.initialized) return; + + const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY'); + if (!keyHex) { + console.warn( + '[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)' + ); + const randomBytes = crypto.getRandomValues(new Uint8Array(32)); + this.masterKey = await this.importKey(this.bytesToHex(randomBytes)); + } else { + if (keyHex.length !== 64) { + throw new Error('AUTH_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'); + } + this.masterKey = await this.importKey(keyHex); + } + + this.initialized = true; + } + + /** + * Encrypt a plaintext string + * Returns format: base64(iv):base64(ciphertext) + */ + public async encrypt(plaintext: string): Promise { + await this.initialize(); + + if (!this.masterKey) { + throw new Error('CryptoService not initialized'); + } + + // Generate random IV (12 bytes for AES-GCM) + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Encode plaintext to bytes + const encoded = new TextEncoder().encode(plaintext); + + // Encrypt + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + this.masterKey, + encoded + ); + + // Format: iv:ciphertext (both base64) + const ivBase64 = this.bytesToBase64(iv); + const ciphertextBase64 = this.bytesToBase64(new Uint8Array(encrypted)); + + return `${ivBase64}:${ciphertextBase64}`; + } + + /** + * Decrypt an encrypted string + * Expects format: base64(iv):base64(ciphertext) + */ + public async decrypt(ciphertext: string): Promise { + await this.initialize(); + + if (!this.masterKey) { + throw new Error('CryptoService not initialized'); + } + + const parts = ciphertext.split(':'); + if (parts.length !== 2) { + throw new Error('Invalid ciphertext format'); + } + + const [ivBase64, encryptedBase64] = parts; + + // Decode from base64 + const iv = this.base64ToBytes(ivBase64); + const encrypted = this.base64ToBytes(encryptedBase64); + + // Decrypt + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + this.masterKey, + encrypted + ); + + // Decode to string + return new TextDecoder().decode(decrypted); + } + + /** + * Check if a string is already encrypted (contains the iv:ciphertext format) + */ + public isEncrypted(value: string): boolean { + if (!value || typeof value !== 'string') return false; + const parts = value.split(':'); + if (parts.length !== 2) return false; + + // Check if both parts look like base64 + try { + this.base64ToBytes(parts[0]); + this.base64ToBytes(parts[1]); + return true; + } catch { + return false; + } + } + + /** + * Import a hex key as CryptoKey + */ + private async importKey(keyHex: string): Promise { + const keyBytes = this.hexToBytes(keyHex); + return await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + } + + /** + * Convert bytes to hex string + */ + private bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + + /** + * Convert hex string to bytes + */ + private hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + } + + /** + * Convert bytes to base64 + */ + private bytesToBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); + } + + /** + * Convert base64 to bytes + */ + private base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + } + + /** + * Generate a new encryption key (for setup) + * Returns a 64-character hex string + */ + public static generateKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } +} + +// Singleton instance +export const cryptoService = new CryptoService(); diff --git a/ts/services/external.auth.service.ts b/ts/services/external.auth.service.ts new file mode 100644 index 0000000..c46f68f --- /dev/null +++ b/ts/services/external.auth.service.ts @@ -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 { + // 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, secret: string): Promise { + 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(); diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index a858a94..d279839 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; import { authGuard } from './core/guards/auth.guard'; +import { adminGuard } from './core/guards/admin.guard'; export const routes: Routes = [ { @@ -7,6 +8,13 @@ export const routes: Routes = [ loadComponent: () => import('./features/login/login.component').then((m) => m.LoginComponent), }, + { + path: 'oauth-callback', + loadComponent: () => + import('./features/oauth-callback/oauth-callback.component').then( + (m) => m.OAuthCallbackComponent + ), + }, { path: '', loadComponent: () => @@ -86,6 +94,39 @@ export const routes: Routes = [ (m) => m.SettingsComponent ), }, + // Admin routes + { + path: 'admin', + canActivate: [adminGuard], + children: [ + { + path: '', + redirectTo: 'auth', + pathMatch: 'full', + }, + { + path: 'auth', + loadComponent: () => + import('./features/admin/auth-providers/auth-providers.component').then( + (m) => m.AuthProvidersComponent + ), + }, + { + path: 'auth/providers/new', + loadComponent: () => + import('./features/admin/auth-providers/provider-form.component').then( + (m) => m.ProviderFormComponent + ), + }, + { + path: 'auth/providers/:id', + loadComponent: () => + import('./features/admin/auth-providers/provider-form.component').then( + (m) => m.ProviderFormComponent + ), + }, + ], + }, ], }, { diff --git a/ui/src/app/core/guards/admin.guard.ts b/ui/src/app/core/guards/admin.guard.ts new file mode 100644 index 0000000..d542432 --- /dev/null +++ b/ui/src/app/core/guards/admin.guard.ts @@ -0,0 +1,27 @@ +import { inject } from '@angular/core'; +import { Router, type CanActivateFn } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +export const adminGuard: CanActivateFn = async () => { + const authService = inject(AuthService); + const router = inject(Router); + + // First check if authenticated + if (!authService.isAuthenticated()) { + // Try to refresh the token + const refreshed = await authService.refreshAccessToken(); + if (!refreshed) { + router.navigate(['/login']); + return false; + } + } + + // Then check if admin + if (!authService.isAdmin()) { + // Not an admin, redirect to dashboard + router.navigate(['/dashboard']); + return false; + } + + return true; +}; diff --git a/ui/src/app/core/services/admin-auth.service.ts b/ui/src/app/core/services/admin-auth.service.ts new file mode 100644 index 0000000..54476cc --- /dev/null +++ b/ui/src/app/core/services/admin-auth.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +// Types +export type TAuthProviderType = 'oidc' | 'ldap'; +export type TAuthProviderStatus = 'active' | 'disabled' | 'testing'; + +export interface IOAuthConfig { + clientId: string; + clientSecretEncrypted: string; + issuer: string; + authorizationUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + scopes: string[]; + callbackUrl: string; +} + +export interface ILdapConfig { + serverUrl: string; + bindDn: string; + bindPasswordEncrypted: string; + baseDn: string; + userSearchFilter: string; + tlsEnabled: boolean; + tlsCaCert?: string; +} + +export interface IAttributeMapping { + email: string; + username: string; + displayName: string; + avatarUrl?: string; + groups?: string; +} + +export interface IProvisioningSettings { + jitEnabled: boolean; + autoLinkByEmail: boolean; + allowedEmailDomains?: string[]; +} + +export interface IAuthProvider { + id: string; + name: string; + displayName: string; + type: TAuthProviderType; + status: TAuthProviderStatus; + priority: number; + oauthConfig?: IOAuthConfig; + ldapConfig?: ILdapConfig; + attributeMapping: IAttributeMapping; + provisioning: IProvisioningSettings; + createdAt: string; + updatedAt: string; + createdById: string; + lastTestedAt?: string; + lastTestResult?: 'success' | 'failure'; + lastTestError?: string; +} + +export interface IPlatformAuthSettings { + localAuthEnabled: boolean; + allowUserRegistration: boolean; + sessionDurationMinutes: number; + defaultProviderId?: string; +} + +export interface IPlatformSettings { + id: string; + auth: IPlatformAuthSettings; + updatedAt: string; + updatedById?: string; +} + +export interface IConnectionTestResult { + success: boolean; + latencyMs: number; + serverInfo?: Record; + error?: string; +} + +export interface ICreateAuthProviderDto { + name: string; + displayName: string; + type: TAuthProviderType; + oauthConfig?: IOAuthConfig; + ldapConfig?: ILdapConfig; + attributeMapping?: IAttributeMapping; + provisioning?: IProvisioningSettings; +} + +export interface IUpdateAuthProviderDto { + displayName?: string; + status?: TAuthProviderStatus; + priority?: number; + oauthConfig?: Partial; + ldapConfig?: Partial; + attributeMapping?: Partial; + provisioning?: Partial; +} + +@Injectable({ providedIn: 'root' }) +export class AdminAuthService { + constructor(private http: HttpClient) {} + + // Provider CRUD + listProviders(): Observable<{ providers: IAuthProvider[] }> { + return this.http.get<{ providers: IAuthProvider[] }>('/api/v1/admin/auth/providers'); + } + + getProvider(id: string): Observable { + return this.http.get(`/api/v1/admin/auth/providers/${id}`); + } + + createProvider(dto: ICreateAuthProviderDto): Observable { + return this.http.post('/api/v1/admin/auth/providers', dto); + } + + updateProvider(id: string, dto: IUpdateAuthProviderDto): Observable { + return this.http.put(`/api/v1/admin/auth/providers/${id}`, dto); + } + + deleteProvider(id: string): Observable<{ message: string }> { + return this.http.delete<{ message: string }>(`/api/v1/admin/auth/providers/${id}`); + } + + testProvider(id: string): Observable { + return this.http.post(`/api/v1/admin/auth/providers/${id}/test`, {}); + } + + // Platform settings + getSettings(): Observable { + return this.http.get('/api/v1/admin/auth/settings'); + } + + updateSettings(settings: Partial<{ auth: Partial }>): Observable { + return this.http.put('/api/v1/admin/auth/settings', settings); + } +} diff --git a/ui/src/app/core/services/auth.service.ts b/ui/src/app/core/services/auth.service.ts index b5ec374..5c20a63 100644 --- a/ui/src/app/core/services/auth.service.ts +++ b/ui/src/app/core/services/auth.service.ts @@ -106,6 +106,19 @@ export class AuthService { } } + /** + * Handle OAuth callback tokens from external providers + */ + handleOAuthCallback(accessToken: string, refreshToken: string, sessionId: string): void { + this._accessToken.set(accessToken); + this._refreshToken.set(refreshToken); + this._sessionId.set(sessionId); + this.saveToStorage(); + + // Fetch user info asynchronously + this.fetchCurrentUser(); + } + private loadFromStorage(): void { const accessToken = localStorage.getItem('accessToken'); const refreshToken = localStorage.getItem('refreshToken'); diff --git a/ui/src/app/features/admin/auth-providers/auth-providers.component.ts b/ui/src/app/features/admin/auth-providers/auth-providers.component.ts new file mode 100644 index 0000000..22f8223 --- /dev/null +++ b/ui/src/app/features/admin/auth-providers/auth-providers.component.ts @@ -0,0 +1,522 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { + AdminAuthService, + type IAuthProvider, + type IPlatformSettings, + type TAuthProviderStatus, +} from '../../../core/services/admin-auth.service'; +import { ToastService } from '../../../core/services/toast.service'; + +@Component({ + selector: 'app-auth-providers', + standalone: true, + template: ` +
+
+
+
+
+ +
+

Authentication Providers

+

Configure OAuth and LDAP authentication

+
+ +
+ + +
+
+
+
+ Platform Settings +
+
+
+ @if (settings()) { +
+
+
+

Local Authentication

+

Allow email/password login

+
+ +
+
+
+

User Registration

+

Allow new account creation

+
+ +
+
+
+

Session Duration

+

{{ formatDuration(settings()!.auth.sessionDurationMinutes) }}

+
+ +
+
+ } @else { +
+
+
+
+
+
+ } +
+
+ + + @if (loading()) { +
+ + + + +
+ } @else if (providers().length === 0) { +
+ + + +

No providers configured

+

Add an OAuth or LDAP provider to enable single sign-on

+ +
+ } @else { +
+ @for (provider of providers(); track provider.id) { +
+
+
+
+ @if (provider.type === 'oidc') { + + + + } @else { + + + + } +
+
+
+

{{ provider.displayName }}

+ {{ provider.status }} + @if (settings()?.auth?.defaultProviderId === provider.id) { + Default + } +
+

{{ provider.name }} · {{ provider.type.toUpperCase() }}

+ @if (provider.type === 'oidc' && provider.oauthConfig) { +

{{ provider.oauthConfig.issuer }}

+ } + @if (provider.type === 'ldap' && provider.ldapConfig) { +

{{ provider.ldapConfig.serverUrl }}

+ } + @if (provider.lastTestedAt) { +
+ @if (provider.lastTestResult === 'success') { + + + + + Connection OK + + } @else { + + + + + Connection Failed + + } + + tested {{ formatDate(provider.lastTestedAt) }} + +
+ } +
+
+ + + +
+
+
+
+ } +
+ } + + + @if (showCreateModal()) { + + } + + + @if (providerToDelete()) { + + } + + + @if (showSettingsModal()) { + + } +
+ `, +}) +export class AuthProvidersComponent implements OnInit { + private adminAuthService = inject(AdminAuthService); + private toastService = inject(ToastService); + + providers = signal([]); + settings = signal(null); + loading = signal(true); + testing = signal(null); + deleting = signal(false); + savingSettings = signal(false); + + showCreateModal = signal(false); + showSettingsModal = signal(false); + providerToDelete = signal(null); + selectedProviderForEdit = signal(null); + + editingSettings = { + sessionDurationMinutes: 10080, + defaultProviderId: undefined as string | undefined, + }; + + ngOnInit(): void { + this.loadData(); + } + + private async loadData(): Promise { + this.loading.set(true); + try { + const [providersRes, settingsRes] = await Promise.all([ + this.adminAuthService.listProviders().toPromise(), + this.adminAuthService.getSettings().toPromise(), + ]); + this.providers.set(providersRes?.providers || []); + if (settingsRes) { + this.settings.set(settingsRes); + this.editingSettings = { + sessionDurationMinutes: settingsRes.auth.sessionDurationMinutes, + defaultProviderId: settingsRes.auth.defaultProviderId, + }; + } + } catch (error) { + this.toastService.error('Failed to load authentication settings'); + } finally { + this.loading.set(false); + } + } + + createProvider(type: 'oidc' | 'ldap'): void { + this.showCreateModal.set(false); + // Navigate to provider form + window.location.href = `/admin/auth/providers/new?type=${type}`; + } + + editProvider(provider: IAuthProvider): void { + window.location.href = `/admin/auth/providers/${provider.id}`; + } + + async testProvider(provider: IAuthProvider): Promise { + this.testing.set(provider.id); + try { + const result = await this.adminAuthService.testProvider(provider.id).toPromise(); + if (result?.success) { + this.toastService.success(`Connection successful (${result.latencyMs}ms)`); + } else { + this.toastService.error(result?.error || 'Connection failed'); + } + // Reload to get updated test results + await this.loadData(); + } catch (error) { + this.toastService.error('Failed to test provider'); + } finally { + this.testing.set(null); + } + } + + confirmDelete(provider: IAuthProvider): void { + this.providerToDelete.set(provider); + } + + async deleteProvider(): Promise { + const provider = this.providerToDelete(); + if (!provider) return; + + this.deleting.set(true); + try { + await this.adminAuthService.deleteProvider(provider.id).toPromise(); + this.toastService.success('Provider deleted'); + this.providerToDelete.set(null); + await this.loadData(); + } catch (error) { + this.toastService.error('Failed to delete provider'); + } finally { + this.deleting.set(false); + } + } + + async toggleLocalAuth(): Promise { + const current = this.settings(); + if (!current) return; + + try { + await this.adminAuthService.updateSettings({ + auth: { localAuthEnabled: !current.auth.localAuthEnabled }, + }).toPromise(); + this.toastService.success('Settings updated'); + await this.loadData(); + } catch (error) { + this.toastService.error('Failed to update settings'); + } + } + + async toggleRegistration(): Promise { + const current = this.settings(); + if (!current) return; + + try { + await this.adminAuthService.updateSettings({ + auth: { allowUserRegistration: !current.auth.allowUserRegistration }, + }).toPromise(); + this.toastService.success('Settings updated'); + await this.loadData(); + } catch (error) { + this.toastService.error('Failed to update settings'); + } + } + + async saveSettings(): Promise { + this.savingSettings.set(true); + try { + await this.adminAuthService.updateSettings({ + auth: { + sessionDurationMinutes: this.editingSettings.sessionDurationMinutes, + defaultProviderId: this.editingSettings.defaultProviderId, + }, + }).toPromise(); + this.toastService.success('Settings saved'); + this.showSettingsModal.set(false); + await this.loadData(); + } catch (error) { + this.toastService.error('Failed to save settings'); + } finally { + this.savingSettings.set(false); + } + } + + getProviderIconClass(type: string): string { + return type === 'oidc' ? 'bg-primary/10 text-primary' : 'bg-accent/10 text-accent'; + } + + getStatusBadgeClass(status: TAuthProviderStatus): string { + switch (status) { + case 'active': + return 'badge-accent'; + case 'testing': + return 'badge-warning'; + case 'disabled': + return 'badge-secondary'; + default: + return 'badge-secondary'; + } + } + + formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes} minutes`; + if (minutes < 1440) return `${Math.round(minutes / 60)} hours`; + return `${Math.round(minutes / 1440)} days`; + } + + formatDate(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + } +} diff --git a/ui/src/app/features/admin/auth-providers/provider-form.component.ts b/ui/src/app/features/admin/auth-providers/provider-form.component.ts new file mode 100644 index 0000000..7dec057 --- /dev/null +++ b/ui/src/app/features/admin/auth-providers/provider-form.component.ts @@ -0,0 +1,705 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { + AdminAuthService, + type IAuthProvider, + type ICreateAuthProviderDto, + type IUpdateAuthProviderDto, + type TAuthProviderType, + type TAuthProviderStatus, +} from '../../../core/services/admin-auth.service'; +import { ToastService } from '../../../core/services/toast.service'; + +@Component({ + selector: 'app-provider-form', + standalone: true, + imports: [FormsModule], + template: ` +
+
+
+
+ +
+

+ {{ isEditMode() ? 'Edit Provider' : 'New ' + (providerType() === 'oidc' ? 'OAuth/OIDC' : 'LDAP') + ' Provider' }} +

+
+ + @if (loading()) { +
+ + + + +
+ } @else { +
+ +
+
+
+
+ Basic Information +
+
+
+
+
+ + +

Lowercase, alphanumeric with hyphens

+
+
+ + +

Shown on login page

+
+
+ @if (isEditMode()) { +
+
+ + +
+
+ + +

Higher = shown first (0-100)

+
+
+ } +
+
+ + + @if (providerType() === 'oidc') { +
+
+
+
+ OAuth / OIDC Configuration +
+
+
+
+ + +

OIDC discovery endpoint base URL

+
+
+
+ + +
+
+ + +

+ @if (isEditMode()) { + Leave empty to keep existing secret + } @else { + Will be encrypted at rest + } +

+
+
+
+ + +

Space-separated OAuth scopes

+
+
+ +
+ + +
+

Add this to your OAuth provider's allowed redirect URIs

+
+ + +
+ + Advanced Settings + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ } + + + @if (providerType() === 'ldap') { +
+
+
+
+ LDAP Configuration +
+
+
+
+ + +

LDAP or LDAPS protocol URL

+
+
+
+ + +
+
+ + +

+ @if (isEditMode()) { + Leave empty to keep existing password + } @else { + Will be encrypted at rest + } +

+
+
+
+ + +

Base DN for user searches

+
+
+ + +

Use double-brace username placeholder

+
+
+ +
+ @if (form.ldapConfig.tlsEnabled) { +
+ + +

PEM-encoded CA certificate for self-signed servers

+
+ } +
+
+ } + + +
+
+
+
+ Attribute Mapping +
+
+
+

+ Map provider attributes to user fields. Use claim names for OAuth or attribute names for LDAP. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +

For future group sync functionality

+
+
+
+ + +
+
+
+
+ User Provisioning +
+
+
+
+ +
+

+ Automatically create user accounts on first login +

+ +
+ +
+

+ Automatically link to existing accounts with matching email addresses +

+ +
+ + +

+ Comma-separated. Leave empty to allow all domains. +

+
+
+
+ + +
+ +
+ @if (isEditMode()) { + + } + +
+
+
+ } +
+ `, +}) +export class ProviderFormComponent implements OnInit { + private adminAuthService = inject(AdminAuthService); + private toastService = inject(ToastService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + loading = signal(true); + saving = signal(false); + testing = signal(false); + isEditMode = signal(false); + providerType = signal('oidc'); + providerId = signal(null); + + scopesInput = 'openid profile email'; + domainsInput = ''; + + form = { + name: '', + displayName: '', + status: 'testing' as TAuthProviderStatus, + priority: 0, + oauthConfig: { + clientId: '', + clientSecretEncrypted: '', + issuer: '', + authorizationUrl: '', + tokenUrl: '', + userInfoUrl: '', + scopes: ['openid', 'profile', 'email'], + callbackUrl: '', + }, + ldapConfig: { + serverUrl: '', + bindDn: '', + bindPasswordEncrypted: '', + baseDn: '', + userSearchFilter: '(uid={{username}})', + tlsEnabled: false, + tlsCaCert: '', + }, + attributeMapping: { + email: 'email', + username: 'preferred_username', + displayName: 'name', + avatarUrl: '', + groups: '', + }, + provisioning: { + jitEnabled: true, + autoLinkByEmail: true, + allowedEmailDomains: [] as string[], + }, + }; + + ngOnInit(): void { + // Check for edit mode + const id = this.route.snapshot.paramMap.get('id'); + if (id && id !== 'new') { + this.isEditMode.set(true); + this.providerId.set(id); + this.loadProvider(id); + } else { + // New provider mode + const type = this.route.snapshot.queryParamMap.get('type') as TAuthProviderType; + if (type && (type === 'oidc' || type === 'ldap')) { + this.providerType.set(type); + this.setDefaultMappings(type); + } + this.loading.set(false); + } + } + + private async loadProvider(id: string): Promise { + try { + const provider = await this.adminAuthService.getProvider(id).toPromise(); + if (provider) { + this.providerType.set(provider.type); + this.form.name = provider.name; + this.form.displayName = provider.displayName; + this.form.status = provider.status; + this.form.priority = provider.priority; + + if (provider.oauthConfig) { + this.form.oauthConfig = { + ...this.form.oauthConfig, + ...provider.oauthConfig, + clientSecretEncrypted: '', // Don't show encrypted secret + }; + this.scopesInput = provider.oauthConfig.scopes.join(' '); + } + + if (provider.ldapConfig) { + this.form.ldapConfig = { + ...this.form.ldapConfig, + ...provider.ldapConfig, + bindPasswordEncrypted: '', // Don't show encrypted password + }; + } + + if (provider.attributeMapping) { + this.form.attributeMapping = { ...this.form.attributeMapping, ...provider.attributeMapping }; + } + + if (provider.provisioning) { + this.form.provisioning = { ...this.form.provisioning, ...provider.provisioning }; + this.domainsInput = provider.provisioning.allowedEmailDomains?.join(', ') || ''; + } + } + } catch (error) { + this.toastService.error('Failed to load provider'); + this.router.navigate(['/admin/auth']); + } finally { + this.loading.set(false); + } + } + + private setDefaultMappings(type: TAuthProviderType): void { + if (type === 'ldap') { + this.form.attributeMapping = { + email: 'mail', + username: 'uid', + displayName: 'cn', + avatarUrl: '', + groups: 'memberOf', + }; + this.form.ldapConfig.userSearchFilter = '(uid={{username}})'; + } + } + + getCallbackUrl(): string { + const baseUrl = window.location.origin; + const providerName = this.form.name || '{provider-name}'; + return `${baseUrl}/api/v1/auth/oauth/${providerName}/callback`; + } + + copyCallbackUrl(): void { + navigator.clipboard.writeText(this.getCallbackUrl()); + this.toastService.success('Callback URL copied'); + } + + async saveProvider(): Promise { + // Parse scopes and domains + this.form.oauthConfig.scopes = this.scopesInput.split(/\s+/).filter(Boolean); + this.form.provisioning.allowedEmailDomains = this.domainsInput + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + + this.saving.set(true); + + try { + if (this.isEditMode()) { + // Update existing provider + const dto: IUpdateAuthProviderDto = { + displayName: this.form.displayName, + status: this.form.status, + priority: this.form.priority, + attributeMapping: this.form.attributeMapping, + provisioning: this.form.provisioning, + }; + + if (this.providerType() === 'oidc') { + dto.oauthConfig = { ...this.form.oauthConfig }; + // Only include secret if changed + if (!dto.oauthConfig.clientSecretEncrypted) { + delete dto.oauthConfig.clientSecretEncrypted; + } + } else { + dto.ldapConfig = { ...this.form.ldapConfig }; + // Only include password if changed + if (!dto.ldapConfig.bindPasswordEncrypted) { + delete dto.ldapConfig.bindPasswordEncrypted; + } + } + + await this.adminAuthService.updateProvider(this.providerId()!, dto).toPromise(); + this.toastService.success('Provider updated'); + } else { + // Create new provider + const dto: ICreateAuthProviderDto = { + name: this.form.name, + displayName: this.form.displayName, + type: this.providerType(), + attributeMapping: this.form.attributeMapping, + provisioning: this.form.provisioning, + }; + + if (this.providerType() === 'oidc') { + dto.oauthConfig = { + ...this.form.oauthConfig, + callbackUrl: this.getCallbackUrl(), + }; + } else { + dto.ldapConfig = this.form.ldapConfig; + } + + await this.adminAuthService.createProvider(dto).toPromise(); + this.toastService.success('Provider created'); + } + + this.router.navigate(['/admin/auth']); + } catch (error: any) { + const message = error?.error?.error || 'Failed to save provider'; + this.toastService.error(message); + } finally { + this.saving.set(false); + } + } + + async testConnection(): Promise { + if (!this.providerId()) return; + + this.testing.set(true); + try { + const result = await this.adminAuthService.testProvider(this.providerId()!).toPromise(); + if (result?.success) { + this.toastService.success(`Connection successful (${result.latencyMs}ms)`); + } else { + this.toastService.error(result?.error || 'Connection failed'); + } + } catch (error) { + this.toastService.error('Failed to test connection'); + } finally { + this.testing.set(false); + } + } + + cancel(): void { + this.router.navigate(['/admin/auth']); + } +} diff --git a/ui/src/app/features/login/login.component.ts b/ui/src/app/features/login/login.component.ts index 8f5bdd6..03c645e 100644 --- a/ui/src/app/features/login/login.component.ts +++ b/ui/src/app/features/login/login.component.ts @@ -1,8 +1,23 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; import { AuthService } from '../../core/services/auth.service'; import { ToastService } from '../../core/services/toast.service'; +import { firstValueFrom } from 'rxjs'; + +interface IPublicProvider { + id: string; + name: string; + displayName: string; + type: 'oidc' | 'ldap'; +} + +interface IProvidersResponse { + providers: IPublicProvider[]; + localAuthEnabled: boolean; + defaultProviderId?: string; +} @Component({ selector: 'app-login', @@ -22,68 +37,202 @@ import { ToastService } from '../../core/services/toast.service';

Registry

- -
- -
-
-
-
- Sign In + @if (loadingProviders()) { +
+ + + +
- -
-
- - -
- -
- - -
-
- - @if (error()) { -
-

{{ error() }}

+ } @else { + + @if (oauthProviders().length > 0) { +
+ @for (provider of oauthProviders(); track provider.id) { + + }
} - + } +
+ } + + + @if (showLdapForm() && selectedLdapProvider()) { + +
+
+
+
+ {{ selectedLdapProvider()!.displayName }} +
+ +
+
+ + +
+
+ + +
+
+ + @if (ldapError()) { +
+

{{ ldapError() }}

+
+ } + +
+ + +
+ + } + + + @if ((oauthProviders().length > 0 || ldapProviders().length > 0) && localAuthEnabled() && !showLdapForm()) { +
+
+ or +
+
+ } + + + @if (localAuthEnabled() && !showLdapForm()) { +
+ +
+
+
+
+ Sign In +
+ +
+
+ + +
+ +
+ + +
+
+ + @if (error()) { +
+

{{ error() }}

+
+ } + + +
+ } + + + @if (!localAuthEnabled() && oauthProviders().length === 0 && ldapProviders().length === 0 && !showLdapForm()) { +
+ + - Signing in... - } @else { - Sign in - } - - +

+ No authentication methods available. Please contact your administrator. +

+
+ } + }

Enterprise Package Registry @@ -92,16 +241,73 @@ import { ToastService } from '../../core/services/toast.service';

`, }) -export class LoginComponent { +export class LoginComponent implements OnInit { private authService = inject(AuthService); private router = inject(Router); private toastService = inject(ToastService); + private http = inject(HttpClient); + // Local login email = ''; password = ''; loading = signal(false); error = signal(null); + // Providers + loadingProviders = signal(true); + localAuthEnabled = signal(true); + oauthProviders = signal([]); + ldapProviders = signal([]); + + // LDAP form + showLdapForm = signal(false); + selectedLdapProvider = signal(null); + ldapUsername = ''; + ldapPassword = ''; + ldapLoading = signal(false); + ldapError = signal(null); + + ngOnInit(): void { + // Check for error in URL params + const params = new URLSearchParams(window.location.search); + const errorParam = params.get('error'); + if (errorParam) { + this.error.set(decodeURIComponent(errorParam)); + } + + this.loadProviders(); + } + + private async loadProviders(): Promise { + try { + const response = await firstValueFrom( + this.http.get('/api/v1/auth/providers') + ); + + this.localAuthEnabled.set(response.localAuthEnabled); + this.oauthProviders.set(response.providers.filter((p) => p.type === 'oidc')); + this.ldapProviders.set(response.providers.filter((p) => p.type === 'ldap')); + + // Auto-redirect to default provider if configured + if (response.defaultProviderId && !this.error()) { + const defaultProvider = response.providers.find((p) => p.id === response.defaultProviderId); + if (defaultProvider) { + if (defaultProvider.type === 'oidc') { + this.loginWithOAuth(defaultProvider); + return; + } else if (defaultProvider.type === 'ldap') { + this.selectLdapProvider(defaultProvider); + } + } + } + } catch (error) { + // If providers endpoint fails, show local auth + console.error('Failed to load providers:', error); + } finally { + this.loadingProviders.set(false); + } + } + async login(): Promise { if (!this.email || !this.password) { this.error.set('Please enter your email and password'); @@ -126,4 +332,62 @@ export class LoginComponent { this.loading.set(false); } } + + loginWithOAuth(provider: IPublicProvider): void { + // Redirect to OAuth authorization endpoint + const returnUrl = encodeURIComponent(window.location.origin + '/dashboard'); + window.location.href = `/api/v1/auth/oauth/${provider.id}/authorize?returnUrl=${returnUrl}`; + } + + selectLdapProvider(provider: IPublicProvider): void { + this.selectedLdapProvider.set(provider); + this.showLdapForm.set(true); + this.ldapUsername = ''; + this.ldapPassword = ''; + this.ldapError.set(null); + } + + cancelLdap(): void { + this.showLdapForm.set(false); + this.selectedLdapProvider.set(null); + } + + async loginWithLdap(): Promise { + const provider = this.selectedLdapProvider(); + if (!provider || !this.ldapUsername || !this.ldapPassword) { + this.ldapError.set('Please enter your username and password'); + return; + } + + this.ldapLoading.set(true); + this.ldapError.set(null); + + try { + const response = await firstValueFrom( + this.http.post<{ + user: { id: string; email: string; username: string; displayName: string; isSystemAdmin: boolean }; + accessToken: string; + refreshToken: string; + sessionId: string; + }>(`/api/v1/auth/ldap/${provider.id}/login`, { + username: this.ldapUsername, + password: this.ldapPassword, + }) + ); + + this.authService.handleOAuthCallback( + response.accessToken, + response.refreshToken, + response.sessionId + ); + + this.toastService.success('Welcome!'); + this.router.navigate(['/dashboard']); + } catch (err: any) { + const message = err?.error?.error || 'Authentication failed'; + this.ldapError.set(message); + } finally { + this.ldapLoading.set(false); + } + } } diff --git a/ui/src/app/features/oauth-callback/oauth-callback.component.ts b/ui/src/app/features/oauth-callback/oauth-callback.component.ts new file mode 100644 index 0000000..4f77caa --- /dev/null +++ b/ui/src/app/features/oauth-callback/oauth-callback.component.ts @@ -0,0 +1,68 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { AuthService } from '../../core/services/auth.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-oauth-callback', + standalone: true, + template: ` +
+
+ @if (error()) { +
+ + + +
+

Authentication Failed

+

{{ error() }}

+ Back to Login + } @else { +
+ + + + +
+

Signing you in...

+

Please wait while we complete authentication

+ } +
+
+ `, +}) +export class OAuthCallbackComponent implements OnInit { + private authService = inject(AuthService); + private router = inject(Router); + private toastService = inject(ToastService); + + error = signal(null); + + ngOnInit(): void { + this.handleCallback(); + } + + private handleCallback(): void { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + const sessionId = params.get('sessionId'); + const errorParam = params.get('error'); + + if (errorParam) { + this.error.set(decodeURIComponent(errorParam)); + return; + } + + if (!accessToken || !refreshToken || !sessionId) { + this.error.set('Missing authentication tokens'); + return; + } + + // Store the tokens and redirect + this.authService.handleOAuthCallback(accessToken, refreshToken, sessionId); + this.toastService.success('Welcome!'); + this.router.navigate(['/dashboard']); + } +} diff --git a/ui/src/app/shared/components/layout/layout.component.ts b/ui/src/app/shared/components/layout/layout.component.ts index 0e33b54..9d142bf 100644 --- a/ui/src/app/shared/components/layout/layout.component.ts +++ b/ui/src/app/shared/components/layout/layout.component.ts @@ -1,7 +1,6 @@ import { Component, computed, inject } from '@angular/core'; import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; -import { ToastService } from '../../../core/services/toast.service'; @Component({ selector: 'app-layout', @@ -65,6 +64,20 @@ import { ToastService } from '../../../core/services/toast.service'; Settings + + + @if (isAdmin()) { +
+

Administration

+ + + + + Authentication + +
+ } @@ -108,6 +121,7 @@ export class LayoutComponent { const name = this.authService.user()?.displayName || 'U'; return name.charAt(0).toUpperCase(); }); + isAdmin = computed(() => this.authService.isAdmin()); logout(): void { this.authService.logout();