feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
This commit is contained in:
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 { Session } from './session.ts';
|
||||
export { AuditLog } from './auditlog.ts';
|
||||
|
||||
// External authentication models
|
||||
export { AuthProvider } from './auth.provider.ts';
|
||||
export { ExternalIdentity } from './external.identity.ts';
|
||||
export { PlatformSettings } from './platform.settings.ts';
|
||||
|
||||
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()
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user