feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
This commit is contained in:
15
changelog.md
15
changelog.md
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# 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)
|
## 2025-11-28 - 1.2.0 - feat(tokens)
|
||||||
Add support for organization-owned API tokens and org-level token management
|
Add support for organization-owned API tokens and org-level token management
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@stack.gallery/registry',
|
name: '@stack.gallery/registry',
|
||||||
version: '1.2.0',
|
version: '1.3.0',
|
||||||
description: 'Enterprise-grade multi-protocol package registry'
|
description: 'Enterprise-grade multi-protocol package registry'
|
||||||
}
|
}
|
||||||
|
|||||||
461
ts/api/handlers/admin.auth.api.ts
Normal file
461
ts/api/handlers/admin.auth.api.ts
Normal file
@@ -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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
ts/api/handlers/oauth.api.ts
Normal file
188
ts/api/handlers/oauth.api.ts
Normal file
@@ -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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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<IApiResponse> {
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { RepositoryApi } from './handlers/repository.api.ts';
|
|||||||
import { PackageApi } from './handlers/package.api.ts';
|
import { PackageApi } from './handlers/package.api.ts';
|
||||||
import { TokenApi } from './handlers/token.api.ts';
|
import { TokenApi } from './handlers/token.api.ts';
|
||||||
import { AuditApi } from './handlers/audit.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 {
|
export interface IApiContext {
|
||||||
request: Request;
|
request: Request;
|
||||||
@@ -57,6 +59,8 @@ export class ApiRouter {
|
|||||||
private packageApi: PackageApi;
|
private packageApi: PackageApi;
|
||||||
private tokenApi: TokenApi;
|
private tokenApi: TokenApi;
|
||||||
private auditApi: AuditApi;
|
private auditApi: AuditApi;
|
||||||
|
private adminAuthApi: AdminAuthApi;
|
||||||
|
private oauthApi: OAuthApi;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.authService = new AuthService();
|
this.authService = new AuthService();
|
||||||
@@ -71,6 +75,8 @@ export class ApiRouter {
|
|||||||
this.packageApi = new PackageApi(this.permissionService);
|
this.packageApi = new PackageApi(this.permissionService);
|
||||||
this.tokenApi = new TokenApi(this.tokenService);
|
this.tokenApi = new TokenApi(this.tokenService);
|
||||||
this.auditApi = new AuditApi(this.permissionService);
|
this.auditApi = new AuditApi(this.permissionService);
|
||||||
|
this.adminAuthApi = new AdminAuthApi();
|
||||||
|
this.oauthApi = new OAuthApi();
|
||||||
|
|
||||||
this.registerRoutes();
|
this.registerRoutes();
|
||||||
}
|
}
|
||||||
@@ -124,6 +130,22 @@ export class ApiRouter {
|
|||||||
|
|
||||||
// Audit routes
|
// Audit routes
|
||||||
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -286,3 +286,140 @@ export interface ICreateTokenDto {
|
|||||||
scopes: ITokenScope[];
|
scopes: ITokenScope[];
|
||||||
expiresAt?: Date;
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConnectionTestResult {
|
||||||
|
success: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
serverInfo?: Record<string, unknown>;
|
||||||
|
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<IOAuthConfig>;
|
||||||
|
ldapConfig?: Partial<ILdapConfig>;
|
||||||
|
attributeMapping?: Partial<IAttributeMapping>;
|
||||||
|
provisioning?: Partial<IProvisioningSettings>;
|
||||||
|
}
|
||||||
|
|||||||
252
ts/models/auth.provider.ts
Normal file
252
ts/models/auth.provider.ts
Normal file
@@ -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<AuthProvider, AuthProvider>
|
||||||
|
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<AuthProvider | null> {
|
||||||
|
return await AuthProvider.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find provider by name (slug)
|
||||||
|
*/
|
||||||
|
public static async findByName(name: string): Promise<AuthProvider | null> {
|
||||||
|
return await AuthProvider.getInstance({ name: name.toLowerCase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active providers (for login page)
|
||||||
|
*/
|
||||||
|
public static async getActiveProviders(): Promise<AuthProvider[]> {
|
||||||
|
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<AuthProvider[]> {
|
||||||
|
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<AuthProvider> {
|
||||||
|
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<AuthProvider> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
ts/models/external.identity.ts
Normal file
142
ts/models/external.identity.ts
Normal file
@@ -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<ExternalIdentity, ExternalIdentity>
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
@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<ExternalIdentity | null> {
|
||||||
|
return await ExternalIdentity.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find by provider and external ID (unique combination)
|
||||||
|
*/
|
||||||
|
public static async findByExternalId(
|
||||||
|
providerId: string,
|
||||||
|
externalId: string
|
||||||
|
): Promise<ExternalIdentity | null> {
|
||||||
|
return await ExternalIdentity.getInstance({ providerId, externalId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all identities for a user
|
||||||
|
*/
|
||||||
|
public static async findByUserId(userId: string): Promise<ExternalIdentity[]> {
|
||||||
|
return await ExternalIdentity.getInstances({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find identity by user and provider
|
||||||
|
*/
|
||||||
|
public static async findByUserAndProvider(
|
||||||
|
userId: string,
|
||||||
|
providerId: string
|
||||||
|
): Promise<ExternalIdentity | null> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}): Promise<ExternalIdentity> {
|
||||||
|
// 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<void> {
|
||||||
|
this.lastLoginAt = new Date();
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update attributes from provider
|
||||||
|
*/
|
||||||
|
public async updateAttributes(data: {
|
||||||
|
externalEmail?: string;
|
||||||
|
externalUsername?: string;
|
||||||
|
rawAttributes?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!this.id) {
|
||||||
|
this.id = await ExternalIdentity.getNewId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,3 +14,8 @@ export { Package } from './package.ts';
|
|||||||
export { ApiToken } from './apitoken.ts';
|
export { ApiToken } from './apitoken.ts';
|
||||||
export { Session } from './session.ts';
|
export { Session } from './session.ts';
|
||||||
export { AuditLog } from './auditlog.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';
|
||||||
|
|||||||
90
ts/models/platform.settings.ts
Normal file
90
ts/models/platform.settings.ts
Normal file
@@ -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<PlatformSettings, PlatformSettings>
|
||||||
|
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<PlatformSettings> {
|
||||||
|
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<IPlatformAuthSettings>,
|
||||||
|
updatedById?: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
this.id = 'singleton';
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,16 @@ export class User extends plugins.smartdata.SmartDataDbDoc<User, User> implement
|
|||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public updatedAt: Date = new Date();
|
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
|
* Create a new user instance
|
||||||
*/
|
*/
|
||||||
|
|||||||
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
@@ -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<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle OAuth/OIDC callback
|
||||||
|
* @param data - Callback data including code and state
|
||||||
|
* @returns External user info from the provider
|
||||||
|
*/
|
||||||
|
handleCallback?(data: IOAuthCallbackData): Promise<IExternalUserInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with credentials (LDAP)
|
||||||
|
* @param username - Username
|
||||||
|
* @param password - Password
|
||||||
|
* @returns External user info if authentication succeeds
|
||||||
|
*/
|
||||||
|
authenticateCredentials?(username: string, password: string): Promise<IExternalUserInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to the provider
|
||||||
|
* @returns Connection test result
|
||||||
|
*/
|
||||||
|
testConnection(): Promise<IConnectionTestResult>;
|
||||||
|
}
|
||||||
8
ts/services/auth/strategies/index.ts
Normal file
8
ts/services/auth/strategies/index.ts
Normal file
@@ -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';
|
||||||
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
@@ -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<IExternalUserInfo> {
|
||||||
|
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<IConnectionTestResult> {
|
||||||
|
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<ILdapEntry> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
@@ -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<string> {
|
||||||
|
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<IExternalUserInfo> {
|
||||||
|
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<IConnectionTestResult> {
|
||||||
|
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<ITokenResponse> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<IOIDCDiscovery> {
|
||||||
|
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<string, unknown>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
ts/services/crypto.service.ts
Normal file
178
ts/services/crypto.service.ts
Normal file
@@ -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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
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();
|
||||||
568
ts/services/external.auth.service.ts
Normal file
568
ts/services/external.auth.service.ts
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
/**
|
||||||
|
* External Auth Service for Stack.Gallery Registry
|
||||||
|
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||||
|
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||||
|
import { AuditService } from './audit.service.ts';
|
||||||
|
import { cryptoService } from './crypto.service.ts';
|
||||||
|
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||||
|
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||||
|
|
||||||
|
export interface IOAuthState {
|
||||||
|
providerId: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
nonce: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExternalAuthService {
|
||||||
|
private strategyFactory: AuthStrategyFactory;
|
||||||
|
private authService: AuthService;
|
||||||
|
private auditService: AuditService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.strategyFactory = new AuthStrategyFactory(cryptoService);
|
||||||
|
this.authService = new AuthService();
|
||||||
|
this.auditService = new AuditService({ actorType: 'system' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate OAuth flow - returns authorization URL and state
|
||||||
|
*/
|
||||||
|
public async initiateOAuth(
|
||||||
|
providerId: string,
|
||||||
|
returnUrl?: string
|
||||||
|
): Promise<{ authUrl: string; state: string }> {
|
||||||
|
const provider = await AuthProvider.findById(providerId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Provider not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.status !== 'active') {
|
||||||
|
throw new Error('Provider is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.type !== 'oidc') {
|
||||||
|
throw new Error('Provider is not an OAuth/OIDC provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = this.strategyFactory.create(provider);
|
||||||
|
if (!strategy.getAuthorizationUrl) {
|
||||||
|
throw new Error('Provider does not support OAuth flow');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate state with encoded data
|
||||||
|
const state = await this.generateState(providerId, returnUrl);
|
||||||
|
const nonce = crypto.randomUUID();
|
||||||
|
|
||||||
|
const authUrl = await strategy.getAuthorizationUrl(state, nonce);
|
||||||
|
|
||||||
|
return { authUrl, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle OAuth callback - exchange code for user and create session
|
||||||
|
*/
|
||||||
|
public async handleOAuthCallback(
|
||||||
|
data: IOAuthCallbackData,
|
||||||
|
options: { ipAddress?: string; userAgent?: string } = {}
|
||||||
|
): Promise<IAuthResult> {
|
||||||
|
// Validate state
|
||||||
|
const stateData = await this.validateState(data.state);
|
||||||
|
if (!stateData) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCode: 'INVALID_STATE',
|
||||||
|
errorMessage: 'Invalid or expired state',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider
|
||||||
|
const provider = await AuthProvider.findById(stateData.providerId);
|
||||||
|
if (!provider || provider.status !== 'active') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCode: 'PROVIDER_INACTIVE',
|
||||||
|
errorMessage: 'Provider not found or inactive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OAuth callback
|
||||||
|
const strategy = this.strategyFactory.create(provider);
|
||||||
|
if (!strategy.handleCallback) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCode: 'INVALID_PROVIDER',
|
||||||
|
errorMessage: 'Provider does not support OAuth callback',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let externalUser: IExternalUserInfo;
|
||||||
|
try {
|
||||||
|
externalUser = await strategy.handleCallback(data);
|
||||||
|
} catch (error) {
|
||||||
|
await this.auditService.log('USER_LOGIN', 'user', {
|
||||||
|
success: false,
|
||||||
|
metadata: {
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCode: 'PROVIDER_ERROR',
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Authentication failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const session = await Session.createSession({
|
||||||
|
userId: user.id,
|
||||||
|
userAgent: options.userAgent || '',
|
||||||
|
ipAddress: options.ipAddress || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate tokens using the existing AuthService approach
|
||||||
|
const accessToken = await this.generateAccessToken(user, session.id);
|
||||||
|
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||||
|
|
||||||
|
// Update user last login
|
||||||
|
user.lastLoginAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: user.id,
|
||||||
|
actorType: 'user',
|
||||||
|
actorIp: options.ipAddress,
|
||||||
|
actorUserAgent: options.userAgent,
|
||||||
|
}).log('USER_LOGIN', 'user', {
|
||||||
|
resourceId: user.id,
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
isNewUser: isNew,
|
||||||
|
authMethod: 'oauth',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
sessionId: session.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with LDAP credentials
|
||||||
|
*/
|
||||||
|
public async authenticateLdap(
|
||||||
|
providerId: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options: { ipAddress?: string; userAgent?: string } = {}
|
||||||
|
): Promise<IAuthResult> {
|
||||||
|
const provider = await AuthProvider.findById(providerId);
|
||||||
|
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCode: 'INVALID_PROVIDER',
|
||||||
|
errorMessage: 'Invalid LDAP provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = this.strategyFactory.create(provider);
|
||||||
|
if (!strategy.authenticateCredentials) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCode: 'INVALID_PROVIDER',
|
||||||
|
errorMessage: 'Provider does not support credential authentication',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let externalUser: IExternalUserInfo;
|
||||||
|
try {
|
||||||
|
externalUser = await strategy.authenticateCredentials(username, password);
|
||||||
|
} catch (error) {
|
||||||
|
await this.auditService.log('USER_LOGIN', 'user', {
|
||||||
|
success: false,
|
||||||
|
metadata: {
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
username,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCode: 'AUTH_FAILED',
|
||||||
|
errorMessage: 'Invalid credentials',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const session = await Session.createSession({
|
||||||
|
userId: user.id,
|
||||||
|
userAgent: options.userAgent || '',
|
||||||
|
ipAddress: options.ipAddress || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const accessToken = await this.generateAccessToken(user, session.id);
|
||||||
|
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||||
|
|
||||||
|
// Update user last login
|
||||||
|
user.lastLoginAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: user.id,
|
||||||
|
actorType: 'user',
|
||||||
|
actorIp: options.ipAddress,
|
||||||
|
actorUserAgent: options.userAgent,
|
||||||
|
}).log('USER_LOGIN', 'user', {
|
||||||
|
resourceId: user.id,
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
isNewUser: isNew,
|
||||||
|
authMethod: 'ldap',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
sessionId: session.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link an external provider to an existing user
|
||||||
|
*/
|
||||||
|
public async linkProvider(
|
||||||
|
userId: string,
|
||||||
|
providerId: string,
|
||||||
|
externalUser: IExternalUserInfo
|
||||||
|
): Promise<ExternalIdentity> {
|
||||||
|
// Check if this external ID is already linked to another user
|
||||||
|
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.userId === userId) {
|
||||||
|
// Already linked to this user, just update
|
||||||
|
await existing.updateAttributes({
|
||||||
|
externalEmail: externalUser.email,
|
||||||
|
externalUsername: externalUser.username,
|
||||||
|
rawAttributes: externalUser.rawAttributes,
|
||||||
|
});
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
throw new Error('This external account is already linked to another user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new identity link
|
||||||
|
const identity = await ExternalIdentity.createIdentity({
|
||||||
|
userId,
|
||||||
|
providerId,
|
||||||
|
externalId: externalUser.externalId,
|
||||||
|
externalEmail: externalUser.email,
|
||||||
|
externalUsername: externalUser.username,
|
||||||
|
rawAttributes: externalUser.rawAttributes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user's external identity IDs
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (user) {
|
||||||
|
user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id];
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await this.auditService.log('USER_UPDATED', 'user', {
|
||||||
|
resourceId: userId,
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
action: 'link_provider',
|
||||||
|
providerId,
|
||||||
|
externalId: externalUser.externalId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink an external provider from a user
|
||||||
|
*/
|
||||||
|
public async unlinkProvider(userId: string, providerId: string): Promise<boolean> {
|
||||||
|
const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId);
|
||||||
|
if (!identity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user still has another auth method
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
const otherIdentities = await ExternalIdentity.findByUserId(userId);
|
||||||
|
const hasLocalAuth = user.canUseLocalAuth && user.passwordHash;
|
||||||
|
|
||||||
|
if (otherIdentities.length <= 1 && !hasLocalAuth) {
|
||||||
|
throw new Error('Cannot unlink last authentication method');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove identity
|
||||||
|
await identity.delete();
|
||||||
|
|
||||||
|
// Update user's external identity IDs
|
||||||
|
user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await this.auditService.log('USER_UPDATED', 'user', {
|
||||||
|
resourceId: userId,
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
action: 'unlink_provider',
|
||||||
|
providerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test provider connection
|
||||||
|
*/
|
||||||
|
public async testConnection(providerId: string): Promise<IConnectionTestResult> {
|
||||||
|
const provider = await AuthProvider.findById(providerId);
|
||||||
|
if (!provider) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: 'Provider not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = this.strategyFactory.create(provider);
|
||||||
|
const result = await strategy.testConnection();
|
||||||
|
|
||||||
|
// Update provider test status
|
||||||
|
await provider.updateTestResult(result.success, result.error);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find or create user from external authentication
|
||||||
|
*/
|
||||||
|
private async findOrCreateUser(
|
||||||
|
provider: AuthProvider,
|
||||||
|
externalUser: IExternalUserInfo,
|
||||||
|
options: { ipAddress?: string } = {}
|
||||||
|
): Promise<{ user: User; isNew: boolean }> {
|
||||||
|
// 1. Check if external identity already exists
|
||||||
|
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||||
|
provider.id,
|
||||||
|
externalUser.externalId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIdentity) {
|
||||||
|
const user = await User.findById(existingIdentity.userId);
|
||||||
|
if (user) {
|
||||||
|
// Update identity with latest info
|
||||||
|
await existingIdentity.updateAttributes({
|
||||||
|
externalEmail: externalUser.email,
|
||||||
|
externalUsername: externalUser.username,
|
||||||
|
rawAttributes: externalUser.rawAttributes,
|
||||||
|
});
|
||||||
|
return { user, isNew: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try to link by email if enabled
|
||||||
|
if (provider.provisioning.autoLinkByEmail && externalUser.email) {
|
||||||
|
const existingUser = await User.findByEmail(externalUser.email);
|
||||||
|
if (existingUser) {
|
||||||
|
await this.linkProvider(existingUser.id, provider.id, externalUser);
|
||||||
|
return { user: existingUser, isNew: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create new user if JIT is enabled
|
||||||
|
if (!provider.provisioning.jitEnabled) {
|
||||||
|
throw new Error('User not found and JIT provisioning is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check domain restrictions
|
||||||
|
if (provider.provisioning.allowedEmailDomains?.length) {
|
||||||
|
const domain = externalUser.email.split('@')[1];
|
||||||
|
if (!provider.provisioning.allowedEmailDomains.includes(domain)) {
|
||||||
|
throw new Error(`Email domain ${domain} is not allowed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique username
|
||||||
|
let username = externalUser.username || externalUser.email.split('@')[0];
|
||||||
|
username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
|
||||||
|
// Ensure username is unique
|
||||||
|
let counter = 0;
|
||||||
|
let finalUsername = username;
|
||||||
|
while (await User.findByUsername(finalUsername)) {
|
||||||
|
counter++;
|
||||||
|
finalUsername = `${username}${counter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = new User();
|
||||||
|
user.id = await User.getNewId();
|
||||||
|
user.email = externalUser.email.toLowerCase();
|
||||||
|
user.username = finalUsername;
|
||||||
|
user.displayName = externalUser.displayName || finalUsername;
|
||||||
|
user.avatarUrl = externalUser.avatarUrl;
|
||||||
|
user.status = 'active';
|
||||||
|
user.emailVerified = true; // Trust the provider
|
||||||
|
user.canUseLocalAuth = false; // No password set
|
||||||
|
user.provisionedByProviderId = provider.id;
|
||||||
|
user.passwordHash = ''; // No local password
|
||||||
|
user.createdAt = new Date();
|
||||||
|
user.updatedAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Link external identity
|
||||||
|
await this.linkProvider(user.id, provider.id, externalUser);
|
||||||
|
|
||||||
|
return { user, isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate OAuth state token
|
||||||
|
*/
|
||||||
|
private async generateState(providerId: string, returnUrl?: string): Promise<string> {
|
||||||
|
const stateData: IOAuthState = {
|
||||||
|
providerId,
|
||||||
|
returnUrl,
|
||||||
|
nonce: crypto.randomUUID(),
|
||||||
|
exp: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encode as base64
|
||||||
|
return btoa(JSON.stringify(stateData));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate OAuth state token
|
||||||
|
*/
|
||||||
|
private async validateState(state: string): Promise<IOAuthState | null> {
|
||||||
|
try {
|
||||||
|
const stateData: IOAuthState = JSON.parse(atob(state));
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (stateData.exp < Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stateData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token (mirrors AuthService logic)
|
||||||
|
*/
|
||||||
|
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||||
|
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiresIn = 15 * 60; // 15 minutes
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
sessionId,
|
||||||
|
type: 'access',
|
||||||
|
iat: now,
|
||||||
|
exp: now + expiresIn,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.signJwt(payload, jwtSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate refresh token (mirrors AuthService logic)
|
||||||
|
*/
|
||||||
|
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||||||
|
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiresIn = 7 * 24 * 60 * 60; // 7 days
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
sessionId,
|
||||||
|
type: 'refresh',
|
||||||
|
iat: now,
|
||||||
|
exp: now + expiresIn,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.signJwt(payload, jwtSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign JWT token
|
||||||
|
*/
|
||||||
|
private async signJwt(payload: Record<string, unknown>, secret: string): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
|
||||||
|
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||||||
|
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||||||
|
|
||||||
|
const data = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const encodedSignature = this.base64UrlEncode(
|
||||||
|
String.fromCharCode(...new Uint8Array(signature))
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${data}.${encodedSignature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64 URL encode
|
||||||
|
*/
|
||||||
|
private base64UrlEncode(str: string): string {
|
||||||
|
const base64 = btoa(str);
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const externalAuthService = new ExternalAuthService();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { authGuard } from './core/guards/auth.guard';
|
import { authGuard } from './core/guards/auth.guard';
|
||||||
|
import { adminGuard } from './core/guards/admin.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -7,6 +8,13 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/login/login.component').then((m) => m.LoginComponent),
|
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: '',
|
path: '',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
@@ -86,6 +94,39 @@ export const routes: Routes = [
|
|||||||
(m) => m.SettingsComponent
|
(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
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
27
ui/src/app/core/guards/admin.guard.ts
Normal file
27
ui/src/app/core/guards/admin.guard.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<IOAuthConfig>;
|
||||||
|
ldapConfig?: Partial<ILdapConfig>;
|
||||||
|
attributeMapping?: Partial<IAttributeMapping>;
|
||||||
|
provisioning?: Partial<IProvisioningSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<IAuthProvider> {
|
||||||
|
return this.http.get<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createProvider(dto: ICreateAuthProviderDto): Observable<IAuthProvider> {
|
||||||
|
return this.http.post<IAuthProvider>('/api/v1/admin/auth/providers', dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProvider(id: string, dto: IUpdateAuthProviderDto): Observable<IAuthProvider> {
|
||||||
|
return this.http.put<IAuthProvider>(`/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<IConnectionTestResult> {
|
||||||
|
return this.http.post<IConnectionTestResult>(`/api/v1/admin/auth/providers/${id}/test`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform settings
|
||||||
|
getSettings(): Observable<IPlatformSettings> {
|
||||||
|
return this.http.get<IPlatformSettings>('/api/v1/admin/auth/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(settings: Partial<{ auth: Partial<IPlatformAuthSettings> }>): Observable<IPlatformSettings> {
|
||||||
|
return this.http.put<IPlatformSettings>('/api/v1/admin/auth/settings', settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
private loadFromStorage(): void {
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
const accessToken = localStorage.getItem('accessToken');
|
||||||
const refreshToken = localStorage.getItem('refreshToken');
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<div class="p-6 max-w-7xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="section-header mb-2">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="section-label">Admin</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-mono text-2xl font-bold text-foreground">Authentication Providers</h1>
|
||||||
|
<p class="font-mono text-sm text-muted-foreground mt-1">Configure OAuth and LDAP authentication</p>
|
||||||
|
</div>
|
||||||
|
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Add Provider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Settings Card -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
@if (settings()) {
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-sm font-medium text-foreground">Local Authentication</p>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">Allow email/password login</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="toggleLocalAuth()"
|
||||||
|
[class]="settings()!.auth.localAuthEnabled ? 'badge-accent' : 'badge-secondary'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ settings()!.auth.localAuthEnabled ? 'Enabled' : 'Disabled' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-sm font-medium text-foreground">User Registration</p>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">Allow new account creation</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="toggleRegistration()"
|
||||||
|
[class]="settings()!.auth.allowUserRegistration ? 'badge-accent' : 'badge-secondary'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ settings()!.auth.allowUserRegistration ? 'Enabled' : 'Disabled' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-sm font-medium text-foreground">Session Duration</p>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">{{ formatDuration(settings()!.auth.sessionDurationMinutes) }}</p>
|
||||||
|
</div>
|
||||||
|
<button (click)="showSettingsModal.set(true)" class="btn-ghost btn-sm">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="animate-pulse flex space-x-4">
|
||||||
|
<div class="flex-1 space-y-2 py-1">
|
||||||
|
<div class="h-4 bg-muted"></div>
|
||||||
|
<div class="h-4 bg-muted w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Providers List -->
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
} @else if (providers().length === 0) {
|
||||||
|
<div class="card card-content text-center py-12">
|
||||||
|
<svg class="w-16 h-16 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-mono text-lg font-medium text-foreground mb-2">No providers configured</h3>
|
||||||
|
<p class="font-mono text-sm text-muted-foreground mb-4">Add an OAuth or LDAP provider to enable single sign-on</p>
|
||||||
|
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||||
|
Add Provider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="space-y-4">
|
||||||
|
@for (provider of providers(); track provider.id) {
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-12 h-12 flex items-center justify-center flex-shrink-0" [class]="getProviderIconClass(provider.type)">
|
||||||
|
@if (provider.type === 'oidc') {
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="font-mono font-semibold text-foreground">{{ provider.displayName }}</h3>
|
||||||
|
<span [class]="getStatusBadgeClass(provider.status)">{{ provider.status }}</span>
|
||||||
|
@if (settings()?.auth?.defaultProviderId === provider.id) {
|
||||||
|
<span class="badge-primary">Default</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-sm text-muted-foreground">{{ provider.name }} · {{ provider.type.toUpperCase() }}</p>
|
||||||
|
@if (provider.type === 'oidc' && provider.oauthConfig) {
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.oauthConfig.issuer }}</p>
|
||||||
|
}
|
||||||
|
@if (provider.type === 'ldap' && provider.ldapConfig) {
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.ldapConfig.serverUrl }}</p>
|
||||||
|
}
|
||||||
|
@if (provider.lastTestedAt) {
|
||||||
|
<div class="flex items-center gap-2 mt-2 font-mono text-xs">
|
||||||
|
@if (provider.lastTestResult === 'success') {
|
||||||
|
<span class="text-accent flex items-center gap-1">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Connection OK
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-destructive flex items-center gap-1">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Connection Failed
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
tested {{ formatDate(provider.lastTestedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
(click)="testProvider(provider)"
|
||||||
|
[disabled]="testing() === provider.id"
|
||||||
|
class="btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
@if (testing() === provider.id) {
|
||||||
|
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
Test
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button (click)="editProvider(provider)" class="btn-ghost btn-sm">Edit</button>
|
||||||
|
<button (click)="confirmDelete(provider)" class="btn-ghost btn-sm text-destructive hover:text-destructive">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
@if (showCreateModal()) {
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||||
|
<div class="card w-full max-w-md mx-4 modal-content">
|
||||||
|
<div class="card-header flex items-center justify-between">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">Select Provider Type</span>
|
||||||
|
</div>
|
||||||
|
<button (click)="showCreateModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-content space-y-3">
|
||||||
|
<button
|
||||||
|
(click)="createProvider('oidc')"
|
||||||
|
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-mono text-sm font-semibold text-foreground">OAuth / OIDC</h4>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">Google, Azure AD, Okta, Auth0, etc.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="createProvider('ldap')"
|
||||||
|
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-mono text-sm font-semibold text-foreground">LDAP / Active Directory</h4>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">Enterprise directory service</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
@if (providerToDelete()) {
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||||
|
<div class="card w-full max-w-md mx-4 modal-content">
|
||||||
|
<div class="card-header flex items-center justify-between">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator bg-destructive"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">Delete Provider</span>
|
||||||
|
</div>
|
||||||
|
<button (click)="providerToDelete.set(null)" class="btn-ghost btn-sm p-1">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p class="font-mono text-sm text-foreground">
|
||||||
|
Are you sure you want to delete <strong>{{ providerToDelete()!.displayName }}</strong>?
|
||||||
|
</p>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-2">
|
||||||
|
Users who signed in with this provider will no longer be able to authenticate through it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer flex justify-end gap-3">
|
||||||
|
<button (click)="providerToDelete.set(null)" class="btn-secondary btn-md">Cancel</button>
|
||||||
|
<button (click)="deleteProvider()" [disabled]="deleting()" class="btn-destructive btn-md">
|
||||||
|
@if (deleting()) {
|
||||||
|
Deleting...
|
||||||
|
} @else {
|
||||||
|
Delete
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
@if (showSettingsModal()) {
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||||
|
<div class="card w-full max-w-md mx-4 modal-content">
|
||||||
|
<div class="card-header flex items-center justify-between">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||||
|
</div>
|
||||||
|
<button (click)="showSettingsModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-content space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Session Duration (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
[value]="editingSettings.sessionDurationMinutes"
|
||||||
|
(input)="editingSettings.sessionDurationMinutes = +($any($event.target).value)"
|
||||||
|
class="input"
|
||||||
|
min="60"
|
||||||
|
max="43200"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">How long user sessions remain valid (60-43200 minutes)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Default Provider</label>
|
||||||
|
<select
|
||||||
|
[value]="editingSettings.defaultProviderId || ''"
|
||||||
|
(change)="editingSettings.defaultProviderId = $any($event.target).value || undefined"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
<option value="">None (show all options)</option>
|
||||||
|
@for (provider of providers(); track provider.id) {
|
||||||
|
@if (provider.status === 'active') {
|
||||||
|
<option [value]="provider.id">{{ provider.displayName }}</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Automatically redirect to this provider on login</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer flex justify-end gap-3">
|
||||||
|
<button (click)="showSettingsModal.set(false)" class="btn-secondary btn-md">Cancel</button>
|
||||||
|
<button (click)="saveSettings()" [disabled]="savingSettings()" class="btn-primary btn-md">
|
||||||
|
@if (savingSettings()) {
|
||||||
|
Saving...
|
||||||
|
} @else {
|
||||||
|
Save
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AuthProvidersComponent implements OnInit {
|
||||||
|
private adminAuthService = inject(AdminAuthService);
|
||||||
|
private toastService = inject(ToastService);
|
||||||
|
|
||||||
|
providers = signal<IAuthProvider[]>([]);
|
||||||
|
settings = signal<IPlatformSettings | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
testing = signal<string | null>(null);
|
||||||
|
deleting = signal(false);
|
||||||
|
savingSettings = signal(false);
|
||||||
|
|
||||||
|
showCreateModal = signal(false);
|
||||||
|
showSettingsModal = signal(false);
|
||||||
|
providerToDelete = signal<IAuthProvider | null>(null);
|
||||||
|
selectedProviderForEdit = signal<IAuthProvider | null>(null);
|
||||||
|
|
||||||
|
editingSettings = {
|
||||||
|
sessionDurationMinutes: 10080,
|
||||||
|
defaultProviderId: undefined as string | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadData(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: `
|
||||||
|
<div class="p-6 max-w-4xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="section-header mb-2">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="section-label">Admin / Auth Providers</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-mono text-2xl font-bold text-foreground">
|
||||||
|
{{ isEditMode() ? 'Edit Provider' : 'New ' + (providerType() === 'oidc' ? 'OAuth/OIDC' : 'LDAP') + ' Provider' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form (ngSubmit)="saveProvider()" class="space-y-6">
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">Basic Information</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Name (identifier)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.name"
|
||||||
|
name="name"
|
||||||
|
class="input"
|
||||||
|
placeholder="google, azure-ad, corp-ldap"
|
||||||
|
required
|
||||||
|
[disabled]="isEditMode()"
|
||||||
|
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase, alphanumeric with hyphens</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.displayName"
|
||||||
|
name="displayName"
|
||||||
|
class="input"
|
||||||
|
placeholder="Google SSO, Corporate LDAP"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Shown on login page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (isEditMode()) {
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Status</label>
|
||||||
|
<select [(ngModel)]="form.status" name="status" class="input">
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="testing">Testing</option>
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Priority</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="form.priority"
|
||||||
|
name="priority"
|
||||||
|
class="input"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Higher = shown first (0-100)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OAuth Config -->
|
||||||
|
@if (providerType() === 'oidc') {
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">OAuth / OIDC Configuration</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Issuer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
[(ngModel)]="form.oauthConfig.issuer"
|
||||||
|
name="issuer"
|
||||||
|
class="input"
|
||||||
|
placeholder="https://accounts.google.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">OIDC discovery endpoint base URL</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.oauthConfig.clientId"
|
||||||
|
name="clientId"
|
||||||
|
class="input"
|
||||||
|
placeholder="your-client-id"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Client Secret</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="form.oauthConfig.clientSecretEncrypted"
|
||||||
|
name="clientSecret"
|
||||||
|
class="input"
|
||||||
|
[placeholder]="isEditMode() ? '••••••••' : 'your-client-secret'"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||||
|
@if (isEditMode()) {
|
||||||
|
Leave empty to keep existing secret
|
||||||
|
} @else {
|
||||||
|
Will be encrypted at rest
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Scopes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="scopesInput"
|
||||||
|
name="scopes"
|
||||||
|
class="input"
|
||||||
|
placeholder="openid profile email"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Space-separated OAuth scopes</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Callback URL</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="getCallbackUrl()"
|
||||||
|
class="input flex-1"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button type="button" (click)="copyCallbackUrl()" class="btn-secondary btn-md">Copy</button>
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Add this to your OAuth provider's allowed redirect URIs</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced OAuth Settings -->
|
||||||
|
<details class="group">
|
||||||
|
<summary class="font-mono text-sm font-medium text-foreground cursor-pointer hover:text-primary">
|
||||||
|
Advanced Settings
|
||||||
|
</summary>
|
||||||
|
<div class="mt-4 space-y-4 pl-4 border-l border-border">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Authorization URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
[(ngModel)]="form.oauthConfig.authorizationUrl"
|
||||||
|
name="authorizationUrl"
|
||||||
|
class="input"
|
||||||
|
placeholder="Override OIDC discovery"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Token URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
[(ngModel)]="form.oauthConfig.tokenUrl"
|
||||||
|
name="tokenUrl"
|
||||||
|
class="input"
|
||||||
|
placeholder="Override OIDC discovery"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">User Info URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
[(ngModel)]="form.oauthConfig.userInfoUrl"
|
||||||
|
name="userInfoUrl"
|
||||||
|
class="input"
|
||||||
|
placeholder="Override OIDC discovery"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- LDAP Config -->
|
||||||
|
@if (providerType() === 'ldap') {
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">LDAP Configuration</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Server URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.ldapConfig.serverUrl"
|
||||||
|
name="serverUrl"
|
||||||
|
class="input"
|
||||||
|
placeholder="ldap://ldap.example.com:389"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">LDAP or LDAPS protocol URL</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Bind DN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.ldapConfig.bindDn"
|
||||||
|
name="bindDn"
|
||||||
|
class="input"
|
||||||
|
placeholder="cn=admin,dc=example,dc=com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Bind Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="form.ldapConfig.bindPasswordEncrypted"
|
||||||
|
name="bindPassword"
|
||||||
|
class="input"
|
||||||
|
[placeholder]="isEditMode() ? '••••••••' : 'your-bind-password'"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||||
|
@if (isEditMode()) {
|
||||||
|
Leave empty to keep existing password
|
||||||
|
} @else {
|
||||||
|
Will be encrypted at rest
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Base DN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.ldapConfig.baseDn"
|
||||||
|
name="baseDn"
|
||||||
|
class="input"
|
||||||
|
placeholder="ou=users,dc=example,dc=com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Base DN for user searches</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">User Search Filter</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.ldapConfig.userSearchFilter"
|
||||||
|
name="userSearchFilter"
|
||||||
|
class="input"
|
||||||
|
[placeholder]="'(uid=' + '{{' + 'username' + '}}' + ')'"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">Use double-brace username placeholder</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="form.ldapConfig.tlsEnabled"
|
||||||
|
name="tlsEnabled"
|
||||||
|
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-sm text-foreground">Enable TLS/StartTLS</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (form.ldapConfig.tlsEnabled) {
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">CA Certificate (optional)</label>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="form.ldapConfig.tlsCaCert"
|
||||||
|
name="tlsCaCert"
|
||||||
|
class="input min-h-[100px] font-mono text-xs"
|
||||||
|
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||||
|
></textarea>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">PEM-encoded CA certificate for self-signed servers</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Attribute Mapping -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">Attribute Mapping</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content space-y-4">
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">
|
||||||
|
Map provider attributes to user fields. Use claim names for OAuth or attribute names for LDAP.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.attributeMapping.email"
|
||||||
|
name="mapEmail"
|
||||||
|
class="input"
|
||||||
|
placeholder="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.attributeMapping.username"
|
||||||
|
name="mapUsername"
|
||||||
|
class="input"
|
||||||
|
[placeholder]="providerType() === 'oidc' ? 'preferred_username' : 'uid'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.attributeMapping.displayName"
|
||||||
|
name="mapDisplayName"
|
||||||
|
class="input"
|
||||||
|
[placeholder]="providerType() === 'oidc' ? 'name' : 'cn'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Avatar URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.attributeMapping.avatarUrl"
|
||||||
|
name="mapAvatarUrl"
|
||||||
|
class="input"
|
||||||
|
placeholder="picture"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Groups (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="form.attributeMapping.groups"
|
||||||
|
name="mapGroups"
|
||||||
|
class="input"
|
||||||
|
[placeholder]="providerType() === 'oidc' ? 'groups' : 'memberOf'"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">For future group sync functionality</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provisioning Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-indicator"></div>
|
||||||
|
<span class="font-mono text-sm font-semibold text-foreground uppercase">User Provisioning</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content space-y-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="form.provisioning.jitEnabled"
|
||||||
|
name="jitEnabled"
|
||||||
|
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-sm text-foreground">Just-in-Time Provisioning</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||||
|
Automatically create user accounts on first login
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="form.provisioning.autoLinkByEmail"
|
||||||
|
name="autoLinkByEmail"
|
||||||
|
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-sm text-foreground">Auto-Link by Email</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||||
|
Automatically link to existing accounts with matching email addresses
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label block mb-1.5">Allowed Email Domains (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="domainsInput"
|
||||||
|
name="allowedDomains"
|
||||||
|
class="input"
|
||||||
|
placeholder="example.com, corp.example.com"
|
||||||
|
/>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||||
|
Comma-separated. Leave empty to allow all domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<button type="button" (click)="cancel()" class="btn-secondary btn-md">Cancel</button>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
@if (isEditMode()) {
|
||||||
|
<button type="button" (click)="testConnection()" [disabled]="testing()" class="btn-secondary btn-md">
|
||||||
|
@if (testing()) {
|
||||||
|
Testing...
|
||||||
|
} @else {
|
||||||
|
Test Connection
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="submit" [disabled]="saving()" class="btn-primary btn-md">
|
||||||
|
@if (saving()) {
|
||||||
|
Saving...
|
||||||
|
} @else {
|
||||||
|
{{ isEditMode() ? 'Save Changes' : 'Create Provider' }}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
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<TAuthProviderType>('oidc');
|
||||||
|
providerId = signal<string | null>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import { ToastService } from '../../core/services/toast.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({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -22,7 +37,127 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
<p class="font-mono text-sm text-muted-foreground mt-2 uppercase tracking-wider">Registry</p>
|
<p class="font-mono text-sm text-muted-foreground mt-2 uppercase tracking-wider">Registry</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login form -->
|
@if (loadingProviders()) {
|
||||||
|
<div class="card p-6 flex items-center justify-center">
|
||||||
|
<svg class="animate-spin h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- SSO Providers -->
|
||||||
|
@if (oauthProviders().length > 0) {
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
@for (provider of oauthProviders(); track provider.id) {
|
||||||
|
<button
|
||||||
|
(click)="loginWithOAuth(provider)"
|
||||||
|
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono text-sm font-medium text-foreground">Continue with {{ provider.displayName }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- LDAP Providers -->
|
||||||
|
@if (ldapProviders().length > 0 && !showLdapForm()) {
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
@for (provider of ldapProviders(); track provider.id) {
|
||||||
|
<button
|
||||||
|
(click)="selectLdapProvider(provider)"
|
||||||
|
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono text-sm font-medium text-foreground">Sign in with {{ provider.displayName }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- LDAP Login Form -->
|
||||||
|
@if (showLdapForm() && selectedLdapProvider()) {
|
||||||
|
<form (ngSubmit)="loginWithLdap()" class="card p-6 space-y-6 mb-6">
|
||||||
|
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||||
|
<div class="terminal-dot dot-red"></div>
|
||||||
|
<div class="terminal-dot dot-orange"></div>
|
||||||
|
<div class="terminal-dot dot-green"></div>
|
||||||
|
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">{{ selectedLdapProvider()!.displayName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="ldapUsername" class="label block mb-1.5">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="ldapUsername"
|
||||||
|
[(ngModel)]="ldapUsername"
|
||||||
|
name="ldapUsername"
|
||||||
|
class="input"
|
||||||
|
placeholder="your.username"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ldapPassword" class="label block mb-1.5">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="ldapPassword"
|
||||||
|
[(ngModel)]="ldapPassword"
|
||||||
|
name="ldapPassword"
|
||||||
|
class="input"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (ldapError()) {
|
||||||
|
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||||
|
<p class="font-mono text-sm text-destructive">{{ ldapError() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="button" (click)="cancelLdap()" class="btn-secondary btn-md flex-1">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button type="submit" [disabled]="ldapLoading()" class="btn-primary btn-md flex-1">
|
||||||
|
@if (ldapLoading()) {
|
||||||
|
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Signing in...
|
||||||
|
} @else {
|
||||||
|
Sign in
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
@if ((oauthProviders().length > 0 || ldapProviders().length > 0) && localAuthEnabled() && !showLdapForm()) {
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="flex-1 border-t border-border"></div>
|
||||||
|
<span class="font-mono text-xs text-muted-foreground uppercase">or</span>
|
||||||
|
<div class="flex-1 border-t border-border"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Local login form -->
|
||||||
|
@if (localAuthEnabled() && !showLdapForm()) {
|
||||||
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
||||||
<!-- Terminal header -->
|
<!-- Terminal header -->
|
||||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||||
@@ -84,6 +219,20 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- No auth available message -->
|
||||||
|
@if (!localAuthEnabled() && oauthProviders().length === 0 && ldapProviders().length === 0 && !showLdapForm()) {
|
||||||
|
<div class="card p-6 text-center">
|
||||||
|
<svg class="w-12 h-12 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<p class="font-mono text-sm text-muted-foreground">
|
||||||
|
No authentication methods available. Please contact your administrator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<p class="text-center font-mono text-xs text-muted-foreground mt-6 uppercase tracking-wider">
|
<p class="text-center font-mono text-xs text-muted-foreground mt-6 uppercase tracking-wider">
|
||||||
Enterprise Package Registry
|
Enterprise Package Registry
|
||||||
@@ -92,16 +241,73 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class LoginComponent {
|
export class LoginComponent implements OnInit {
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private toastService = inject(ToastService);
|
private toastService = inject(ToastService);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
// Local login
|
||||||
email = '';
|
email = '';
|
||||||
password = '';
|
password = '';
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
loadingProviders = signal(true);
|
||||||
|
localAuthEnabled = signal(true);
|
||||||
|
oauthProviders = signal<IPublicProvider[]>([]);
|
||||||
|
ldapProviders = signal<IPublicProvider[]>([]);
|
||||||
|
|
||||||
|
// LDAP form
|
||||||
|
showLdapForm = signal(false);
|
||||||
|
selectedLdapProvider = signal<IPublicProvider | null>(null);
|
||||||
|
ldapUsername = '';
|
||||||
|
ldapPassword = '';
|
||||||
|
ldapLoading = signal(false);
|
||||||
|
ldapError = signal<string | null>(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<void> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.get<IProvidersResponse>('/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<void> {
|
async login(): Promise<void> {
|
||||||
if (!this.email || !this.password) {
|
if (!this.email || !this.password) {
|
||||||
this.error.set('Please enter your email and password');
|
this.error.set('Please enter your email and password');
|
||||||
@@ -126,4 +332,62 @@ export class LoginComponent {
|
|||||||
this.loading.set(false);
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div class="max-w-md w-full text-center">
|
||||||
|
@if (error()) {
|
||||||
|
<div class="w-16 h-16 bg-destructive/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-10 h-10 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Authentication Failed</h1>
|
||||||
|
<p class="font-mono text-sm text-muted-foreground mb-6">{{ error() }}</p>
|
||||||
|
<a href="/login" class="btn-primary btn-md">Back to Login</a>
|
||||||
|
} @else {
|
||||||
|
<div class="w-16 h-16 bg-primary flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="animate-spin w-10 h-10 text-primary-foreground" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Signing you in...</h1>
|
||||||
|
<p class="font-mono text-sm text-muted-foreground">Please wait while we complete authentication</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class OAuthCallbackComponent implements OnInit {
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private router = inject(Router);
|
||||||
|
private toastService = inject(ToastService);
|
||||||
|
|
||||||
|
error = signal<string | null>(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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { ToastService } from '../../../core/services/toast.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-layout',
|
selector: 'app-layout',
|
||||||
@@ -65,6 +64,20 @@ import { ToastService } from '../../../core/services/toast.service';
|
|||||||
</svg>
|
</svg>
|
||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Admin Section -->
|
||||||
|
@if (isAdmin()) {
|
||||||
|
<div class="pt-4 mt-4 border-t border-border">
|
||||||
|
<p class="px-3 mb-2 font-mono text-xs text-muted-foreground uppercase tracking-wider">Administration</p>
|
||||||
|
<a routerLink="/admin/auth" routerLinkActive="bg-primary/10 text-primary"
|
||||||
|
class="nav-link">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
Authentication
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User section -->
|
<!-- User section -->
|
||||||
@@ -108,6 +121,7 @@ export class LayoutComponent {
|
|||||||
const name = this.authService.user()?.displayName || 'U';
|
const name = this.authService.user()?.displayName || 'U';
|
||||||
return name.charAt(0).toUpperCase();
|
return name.charAt(0).toUpperCase();
|
||||||
});
|
});
|
||||||
|
isAdmin = computed(() => this.authService.isAdmin());
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.authService.logout();
|
this.authService.logout();
|
||||||
|
|||||||
Reference in New Issue
Block a user