/** * IAuthProvider implementation for smartregistry * Integrates Stack.Gallery's auth system with smartregistry's protocol handlers */ import * as plugins from '../plugins.ts'; import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts'; import { User } from '../models/user.ts'; import { TokenService } from '../services/token.service.ts'; import { PermissionService, type TAction } from '../services/permission.service.ts'; import { AuditService } from '../services/audit.service.ts'; import { AuthService } from '../services/auth.service.ts'; /** * Request actor representing the authenticated entity making a request */ export interface IStackGalleryActor { type: 'user' | 'api_token' | 'anonymous'; userId?: string; user?: User; tokenId?: string; ip?: string; userAgent?: string; protocols: TRegistryProtocol[]; permissions: { organizationId?: string; repositoryId?: string; canRead: boolean; canWrite: boolean; canDelete: boolean; }; } /** * Auth provider that implements smartregistry's IAuthProvider interface */ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProvider { private tokenService: TokenService; private permissionService: PermissionService; private authService: AuthService; constructor() { this.tokenService = new TokenService(); this.permissionService = new PermissionService(); this.authService = new AuthService(); } /** * Authenticate a request and return the actor * Called by smartregistry for every incoming request */ public async authenticate(request: plugins.smartregistry.IAuthRequest): Promise { const auditContext = AuditService.withContext({ actorIp: request.ip, actorUserAgent: request.userAgent, }); // Extract auth credentials const authHeader = request.headers?.['authorization'] || request.headers?.['Authorization']; // Try Bearer token (API token) if (authHeader?.startsWith('Bearer ')) { const token = authHeader.substring(7); return await this.authenticateWithApiToken(token, request, auditContext); } // Try Basic auth (for npm/other CLI tools) if (authHeader?.startsWith('Basic ')) { const credentials = authHeader.substring(6); return await this.authenticateWithBasicAuth(credentials, request, auditContext); } // Anonymous access return this.createAnonymousActor(request); } /** * Check if actor has permission for the requested action */ public async authorize( actor: plugins.smartregistry.IRequestActor, request: plugins.smartregistry.IAuthorizationRequest ): Promise { const stackActor = actor as IStackGalleryActor; // Anonymous users can only read public packages if (stackActor.type === 'anonymous') { if (request.action === 'read' && request.isPublic) { return { allowed: true }; } return { allowed: false, reason: 'Authentication required', statusCode: 401, }; } // Check protocol access if (!stackActor.protocols.includes(request.protocol as TRegistryProtocol) && !stackActor.protocols.includes('*' as TRegistryProtocol)) { return { allowed: false, reason: `Token does not have access to ${request.protocol} protocol`, statusCode: 403, }; } // Map action to TAction const action = this.mapAction(request.action); // Resolve permissions const permissions = await this.permissionService.resolvePermissions({ userId: stackActor.userId!, organizationId: request.organizationId, repositoryId: request.repositoryId, protocol: request.protocol as TRegistryProtocol, }); // Check permission let allowed = false; switch (action) { case 'read': allowed = permissions.canRead || (request.isPublic ?? false); break; case 'write': allowed = permissions.canWrite; break; case 'delete': allowed = permissions.canDelete; break; case 'admin': allowed = permissions.canAdmin; break; } if (!allowed) { return { allowed: false, reason: `Insufficient permissions for ${request.action} on ${request.resourceType}`, statusCode: 403, }; } return { allowed: true }; } /** * Authenticate using API token */ private async authenticateWithApiToken( rawToken: string, request: plugins.smartregistry.IAuthRequest, auditContext: AuditService ): Promise { const result = await this.tokenService.validateToken(rawToken, request.ip); if (!result.valid || !result.token || !result.user) { await auditContext.logFailure( 'TOKEN_USED', 'api_token', result.errorCode || 'UNKNOWN', result.errorMessage || 'Token validation failed' ); return this.createAnonymousActor(request); } await auditContext.log('TOKEN_USED', 'api_token', { resourceId: result.token.id, success: true, }); return { type: 'api_token', userId: result.user.id, user: result.user, tokenId: result.token.id, ip: request.ip, userAgent: request.userAgent, protocols: result.token.protocols, permissions: { canRead: true, canWrite: true, canDelete: true, }, }; } /** * Authenticate using Basic auth (username:password or username:token) */ private async authenticateWithBasicAuth( credentials: string, request: plugins.smartregistry.IAuthRequest, auditContext: AuditService ): Promise { try { const decoded = atob(credentials); const [username, password] = decoded.split(':'); // If password looks like an API token, try token auth if (password?.startsWith('srg_')) { return await this.authenticateWithApiToken(password, request, auditContext); } // Otherwise try username/password (email/password) const result = await this.authService.login(username, password, { userAgent: request.userAgent, ipAddress: request.ip, }); if (!result.success || !result.user) { return this.createAnonymousActor(request); } return { type: 'user', userId: result.user.id, user: result.user, ip: request.ip, userAgent: request.userAgent, protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'], permissions: { canRead: true, canWrite: true, canDelete: true, }, }; } catch { return this.createAnonymousActor(request); } } /** * Create anonymous actor */ private createAnonymousActor(request: plugins.smartregistry.IAuthRequest): IStackGalleryActor { return { type: 'anonymous', ip: request.ip, userAgent: request.userAgent, protocols: [], permissions: { canRead: false, canWrite: false, canDelete: false, }, }; } /** * Map smartregistry action to our TAction type */ private mapAction(action: string): TAction { switch (action) { case 'read': case 'pull': case 'download': case 'fetch': return 'read'; case 'write': case 'push': case 'publish': case 'upload': return 'write'; case 'delete': case 'unpublish': case 'remove': return 'delete'; case 'admin': case 'manage': return 'admin'; default: return 'read'; } } }