feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support

This commit is contained in:
2025-12-03 22:09:35 +00:00
parent 44e92d48f2
commit d3fd40ce2f
27 changed files with 4512 additions and 61 deletions

252
ts/models/auth.provider.ts Normal file
View 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;
}
}

View 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();
}
}
}

View File

@@ -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';

View 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();
}
}

View File

@@ -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
*/