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

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@stack.gallery/registry',
version: '1.2.0',
version: '1.3.0',
description: 'Enterprise-grade multi-protocol package registry'
}

View 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' },
};
}
}
}

View 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' },
};
}
}
}

View File

@@ -16,6 +16,8 @@ import { RepositoryApi } from './handlers/repository.api.ts';
import { PackageApi } from './handlers/package.api.ts';
import { TokenApi } from './handlers/token.api.ts';
import { AuditApi } from './handlers/audit.api.ts';
import { AdminAuthApi } from './handlers/admin.auth.api.ts';
import { OAuthApi } from './handlers/oauth.api.ts';
export interface IApiContext {
request: Request;
@@ -57,6 +59,8 @@ export class ApiRouter {
private packageApi: PackageApi;
private tokenApi: TokenApi;
private auditApi: AuditApi;
private adminAuthApi: AdminAuthApi;
private oauthApi: OAuthApi;
constructor() {
this.authService = new AuthService();
@@ -71,6 +75,8 @@ export class ApiRouter {
this.packageApi = new PackageApi(this.permissionService);
this.tokenApi = new TokenApi(this.tokenService);
this.auditApi = new AuditApi(this.permissionService);
this.adminAuthApi = new AdminAuthApi();
this.oauthApi = new OAuthApi();
this.registerRoutes();
}
@@ -124,6 +130,22 @@ export class ApiRouter {
// Audit routes
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
// OAuth/External auth routes (public)
this.addRoute('GET', '/api/v1/auth/providers', (ctx) => this.oauthApi.listProviders(ctx));
this.addRoute('GET', '/api/v1/auth/oauth/:id/authorize', (ctx) => this.oauthApi.authorize(ctx));
this.addRoute('GET', '/api/v1/auth/oauth/:id/callback', (ctx) => this.oauthApi.callback(ctx));
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
// Admin auth routes (platform admin only)
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx));
this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx));
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
}
/**

View File

@@ -286,3 +286,140 @@ export interface ICreateTokenDto {
scopes: ITokenScope[];
expiresAt?: Date;
}
// =============================================================================
// External Authentication Types
// =============================================================================
export type TAuthProviderType = 'oidc' | 'ldap';
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
export interface IOAuthConfig {
clientId: string;
clientSecretEncrypted: string; // AES-256-GCM encrypted
issuer: string; // OIDC issuer URL (used for discovery)
authorizationUrl?: string; // Override discovery
tokenUrl?: string; // Override discovery
userInfoUrl?: string; // Override discovery
scopes: string[];
callbackUrl: string;
}
export interface ILdapConfig {
serverUrl: string; // ldap:// or ldaps://
bindDn: string;
bindPasswordEncrypted: string; // AES-256-GCM encrypted
baseDn: string;
userSearchFilter: string; // e.g., "(uid={{username}})" or "(sAMAccountName={{username}})"
tlsEnabled: boolean;
tlsCaCert?: string;
}
export interface IAttributeMapping {
email: string;
username: string;
displayName: string;
avatarUrl?: string;
groups?: string;
}
export interface IProvisioningSettings {
jitEnabled: boolean; // Create user on first login
autoLinkByEmail: boolean; // Link to existing user by email match
allowedEmailDomains?: string[]; // Restrict to specific domains
}
export interface IAuthProvider {
id: string;
name: string;
displayName: string;
type: TAuthProviderType;
status: TAuthProviderStatus;
priority: number;
oauthConfig?: IOAuthConfig;
ldapConfig?: ILdapConfig;
attributeMapping: IAttributeMapping;
provisioning: IProvisioningSettings;
createdAt: Date;
updatedAt: Date;
createdById: string;
lastTestedAt?: Date;
lastTestResult?: 'success' | 'failure';
lastTestError?: string;
}
export interface IExternalIdentity {
id: string;
userId: string;
providerId: string;
externalId: string;
externalEmail?: string;
externalUsername?: string;
rawAttributes?: Record<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
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
*/

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

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

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

View 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,
};
}
}

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

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

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