OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / auth-manager.js

616 lines · javascript ref:
import { WebIdentityProvider } from './auth/providers/web-identity.js'
import { BearerTokenProvider } from './auth/providers/bearer-token.js'
import { KeetIdentityProvider } from './auth/providers/keet-identity.js'
import { SSHKeyProvider } from './auth/providers/ssh-key.js'
import { ChallengeManager } from './auth/challenge-manager.js'
import { RateLimiter } from './auth/rate-limiter.js'

/**
 * Authentication and Authorization System
 *
 * Multi-method authentication with identity linking:
 * - Web Identity: Browser-based ANProto keypairs (auto-generated)
 * - Bearer Tokens: API tokens with embedded permissions
 * - Keet Identity: BIP39 mnemonic-based identities
 * - SSH Keys: Public key authentication
 *
 * Key Concepts:
 * - User: A person with permissions (can have multiple identities)
 * - Identity: An authentication method
 * - Linking: Multiple identities can link to the same user
 */
export class AuthManager {
  constructor(options = {}) {
    this.hyperbee = options.registry
    this.trustedProxies = options.trustedProxies || ['127.0.0.1', '::1', '::ffff:127.0.0.1']

    // Identity registry (provided by server, Autobase-backed)
    this.identityRegistry = options.identityRegistry
    this.db = options.registry // Raw Hyperbee for token storage (not identity data)

    // Initialize challenge manager (with persistent storage)
    this.challengeManager = new ChallengeManager({
      db: this.hyperbee,
      ttl: options.challengeTTL || 5 * 60 * 1000,  // 5 minutes
      cacheTimeout: options.challengeCacheTimeout || 30 * 1000  // 30 seconds
    })

    // Initialize rate limiter (with persistent storage)
    this.rateLimiter = new RateLimiter({
      db: this.hyperbee,
      limits: options.rateLimits || {
        'auth': { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 },
        'api': { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 },
        'git': { tokens: 5000, refillRate: 500, refillInterval: 60 * 1000 },
        'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 }
      },
      cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000  // 10 seconds
    })

    // Register all auth providers
    this.providers = [
      new WebIdentityProvider(this.identityRegistry, this.challengeManager),
      new KeetIdentityProvider(this.identityRegistry),
      new SSHKeyProvider(this.identityRegistry),
      new BearerTokenProvider(this.identityRegistry, this.db)
    ]

    // Get bearer token provider for backward compat
    this.bearerProvider = this.providers.find(p => p.name === 'bearer-token')
  }

  /**
   * Authenticate request using all available providers
   * @param {Object} req - HTTP request
   * @param {Object} options - { skipRateLimit, operation }
   * @returns {Promise<Object|null>} Auth result or null
   */
  async authenticate(req, options = {}) {
    // Apply rate limiting (unless explicitly skipped)
    if (!options.skipRateLimit) {
      const clientIp = this.getClientIp(req)
      const operation = options.operation || 'auth'

      const rateLimit = await this.rateLimiter.check(clientIp, operation)

      if (!rateLimit.allowed) {
        const error = new Error('Rate limit exceeded')
        error.code = 'RATE_LIMIT'
        error.retryAfter = rateLimit.retryAfter
        error.resetAt = rateLimit.resetAt
        throw error
      }
    }

    // Try each provider in order
    const errors = []
    for (const provider of this.providers) {
      const credentials = await provider.extractCredentials(req)

      if (credentials) {
        try {
          const authResult = await provider.authenticate(credentials)

          // Bearer tokens have permissions embedded, don't look up User
          if (authResult.method === 'bearer-token') {
            return {
              userId: authResult.credentials.userId || null,
              identityId: authResult.identityId,
              method: authResult.method,
              username: authResult.credentials.username,
              permissions: this.formatTokenPermissions(authResult),
              verified: true
            }
          }

          // For other methods, look up User via Identity
          const user = await this.identityRegistry.getUserByIdentity(authResult.identityId)

          if (!user && authResult.method === 'web-identity') {
            // Web identity was auto-registered by provider, retry lookup
            const newUser = await this.identityRegistry.getUserByIdentity(authResult.identityId)

            if (newUser) {
              // Deactivated users cannot authenticate
              if (newUser.deactivatedAt) {
                errors.push({ provider: provider.name, error: 'User account is deactivated' })
                continue
              }

              return {
                userId: newUser.userId,
                identityId: authResult.identityId,
                method: authResult.method,
                permissions: this.formatUserPermissions(newUser, null),
                user: newUser,
                verified: true
              }
            }
          }

          if (!user) {
            console.warn(`[Auth] ${provider.name}: authenticated but user not found for identity ${authResult.identityId.substring(0, 12)}...`)
            errors.push({ provider: provider.name, error: 'User not found after authenticate' })
            continue
          }

          // Deactivated users cannot authenticate
          if (user.deactivatedAt) {
            console.warn(`[Auth] ${provider.name}: user ${user.userId.substring(0, 12)}... is deactivated`)
            errors.push({ provider: provider.name, error: 'User account is deactivated' })
            continue
          }

          return {
            userId: user.userId,
            identityId: authResult.identityId,
            method: authResult.method,
            permissions: this.formatUserPermissions(user, null),
            user,
            verified: true
          }
        } catch (err) {
          console.warn(`[Auth] ${provider.name} error: ${err.message}`)
          errors.push({ provider: provider.name, error: err.message })
          continue
        }
      }
    }

    if (errors.length > 0) {
      console.warn('[Auth] All providers failed:', errors.map(e => `${e.provider}: ${e.error}`).join('; '))
    }

    return null
  }

  /**
   * Get permissions for a user
   * @param {Object} user - User object
   * @param {string} repoName - Repository name
   * @returns {Object} Formatted permissions
   */
  formatUserPermissions(user, repoName) {
    // Check repo-specific permissions
    const repoPerms = user.repos.find(r => r.name === repoName || r.name === '*')

    if (repoPerms) {
      return {
        read: repoPerms.permissions.includes('read') || repoPerms.permissions.includes('admin'),
        write: repoPerms.permissions.includes('write') || repoPerms.permissions.includes('admin'),
        admin: repoPerms.permissions.includes('admin'),
        repos: [repoPerms.name]
      }
    }

    // Fall back to global permissions
    return {
      read: user.globalPermissions.includes('read') || user.globalPermissions.includes('admin'),
      write: user.globalPermissions.includes('write') || user.globalPermissions.includes('admin'),
      admin: user.globalPermissions.includes('admin'),
      repos: ['*']
    }
  }

  /**
   * Format bearer token permissions
   * @param {Object} authResult - Bearer token auth result
   * @returns {Object} Formatted permissions
   */
  formatTokenPermissions(authResult) {
    return {
      read: authResult.permissions.includes('read') || authResult.permissions.includes('admin'),
      write: authResult.permissions.includes('write') || authResult.permissions.includes('admin'),
      admin: authResult.permissions.includes('admin'),
      repos: authResult.repos
    }
  }

  /**
   * Check if auth has permission for action on repo
   * @param {Object} auth - Auth result from authenticate()
   * @param {string} action - 'read', 'write', or 'admin'
   * @param {string} repoName - Repository name
   * @returns {boolean}
   */
  hasPermission(auth, action, repoName = null) {
    if (!auth || !auth.verified) return false

    const perms = auth.permissions

    // Admin can do anything
    if (perms.admin) return true

    // Check action
    if (!perms[action]) return false

    // Check repo access
    if (repoName) {
      return perms.repos.includes('*') || perms.repos.includes(repoName)
    }

    return true
  }

  /**
   * Middleware to authenticate and authorize requests
   * @param {Object} req - HTTP request
   * @param {string} action - Required action ('read', 'write', 'admin')
   * @param {string} repoName - Repository name
   * @returns {Promise<Object>} { authorized, auth, reason }
   */
  async authorizeRequest(req, action, repoName = null) {
    const auth = await this.authenticate(req)

    if (!auth) {
      return { authorized: false, auth: null, reason: 'Not authenticated' }
    }

    if (!this.hasPermission(auth, action, repoName)) {
      return { authorized: false, auth, reason: 'Insufficient permissions' }
    }

    return { authorized: true, auth, reason: null }
  }

  // ==================== BACKWARD COMPATIBILITY ====================
  // These methods delegate to BearerTokenProvider for backward compat

  /**
   * Create a new bearer token (backward compat)
   */
  async createToken(options) {
    return this.bearerProvider.createToken(options)
  }

  /**
   * Validate a bearer token (backward compat)
   */
  async validateToken(token) {
    return this.bearerProvider.validateToken(token)
  }

  /**
   * Revoke a bearer token (backward compat)
   */
  async revokeToken(tokenHash) {
    return this.bearerProvider.revokeToken(tokenHash)
  }

  /**
   * List all bearer tokens (backward compat)
   */
  async listAllTokens() {
    return this.bearerProvider.listAllTokens()
  }

  /**
   * Get user tokens (backward compat)
   */
  async getUserTokens(username) {
    return this.bearerProvider.getUserTokens(username)
  }

  /**
   * Create default admin token (backward compat)
   */
  async createDefaultAdmin() {
    const tokens = await this.listAllTokens()
    if (tokens.length > 0) return null

    return this.createToken({
      username: 'admin',
      description: 'Default admin token (generated on first start)',
      permissions: ['read', 'write', 'admin'],
      repos: ['*']
    })
  }

  /**
   * Get auth status (backward compat)
   */
  async getStatus() {
    const tokens = await this.listAllTokens()
    const activeTokens = tokens.filter(t => !t.expiresAt || t.expiresAt > Date.now())

    return {
      enabled: tokens.length > 0,
      totalTokens: tokens.length,
      activeTokens: activeTokens.length,
      users: [...new Set(tokens.map(t => t.username))].length
    }
  }

  /**
   * Clean up expired tokens (backward compat)
   */
  async cleanupExpiredTokens() {
    const tokens = await this.listAllTokens()
    let removed = 0

    for (const tokenData of tokens) {
      if (tokenData.expiresAt && Date.now() > tokenData.expiresAt) {
        await this.revokeToken(tokenData.tokenHash)
        removed++
      }
    }

    return { removed }
  }

  // ==================== NEW IDENTITY LINKING METHODS ====================

  /**
   * Generate new web identity
   * @returns {Promise<Object>} { userId, publicKey, keypair, permissions }
   */
  async generateWebIdentity() {
    const webProvider = this.providers.find(p => p.name === 'web-identity')
    return webProvider.generateIdentity()
  }

  /**
   * Link SSH key to current user
   * @param {string} currentIdentityId - Current identity ID (from web/keet)
   * @param {string} sshPublicKey - SSH public key to link
   * @returns {Promise<Object>} Updated user with all identities
   */
  async linkSSHKey(currentIdentityId, sshPublicKey) {
    // Get current user
    const user = await this.identityRegistry.getUserByIdentity(currentIdentityId)
    if (!user) {
      throw new Error('Current identity not found')
    }

    // Calculate SSH fingerprint
    const fingerprint = this.identityRegistry.calculateSSHFingerprint(sshPublicKey)

    // Create SSH identity
    const sshIdentity = await this.identityRegistry.createIdentity({
      identityId: `ssh:${fingerprint}`,
      method: 'ssh-key',
      credentials: { publicKey: sshPublicKey, fingerprint }
    })

    // Link to user
    await this.identityRegistry.linkIdentity(sshIdentity.identityId, user.userId)

    return this.identityRegistry.getUser(user.userId)
  }

  /**
   * Get user with all linked identities
   * @param {string} identityId - Any linked identity ID
   * @returns {Promise<Object>} { user, identities }
   */
  async getUserWithIdentities(identityId) {
    const user = await this.identityRegistry.getUserByIdentity(identityId)
    if (!user) return null

    const identities = await this.identityRegistry.listUserIdentities(user.userId)

    return { user, identities }
  }

  /**
   * Upgrade user permissions (admin only)
   * @param {string} userId - User ID
   * @param {string} repoName - Repository name (or '*')
   * @param {Array<string>} permissions - Permissions to grant
   * @returns {Promise<Object>} Updated user
   */
  async upgradeUserPermissions(userId, repoName, permissions) {
    return this.identityRegistry.upgradePermissions(userId, repoName, permissions)
  }

  /**
   * Generate authentication challenge
   * @param {Object} options - { context }
   * @returns {Promise<Object>} { challengeId, challenge, expiresAt }
   */
  async generateChallenge(options = {}) {
    return await this.challengeManager.generate(options)
  }

  /**
   * Verify challenge
   * @param {string} challengeId - Challenge ID
   * @param {string} challenge - Challenge string
   * @returns {Promise<boolean>} True if valid
   */
  async verifyChallenge(challengeId, challenge) {
    return await this.challengeManager.verify(challengeId, challenge)
  }

  /**
   * Revoke an identity (admin only)
   * @param {string} identityId - Identity ID
   * @param {Object} options - { reason, revokedBy }
   * @returns {Promise<Object>} Revoked identity
   */
  async revokeIdentity(identityId, options = {}) {
    return this.identityRegistry.revokeIdentity(identityId, options)
  }

  /**
   * Restore a revoked identity (admin only)
   * @param {string} identityId - Identity ID
   * @returns {Promise<Object>} Restored identity
   */
  async restoreIdentity(identityId) {
    return this.identityRegistry.restoreIdentity(identityId)
  }

  /**
   * List all revoked identities (admin only)
   * @returns {Promise<Array>} Array of revoked identities
   */
  async listRevokedIdentities() {
    return this.identityRegistry.listRevokedIdentities()
  }

  /**
   * Get client IP from request
   * @param {Object} req - HTTP request
   * @returns {string} Client IP
   */
  getClientIp(req) {
    const socketIp = req.socket?.remoteAddress || 'unknown'

    // Only trust proxy headers from configured trusted proxies (loopback by default)
    const isTrustedProxy = this.trustedProxies.some(ip => socketIp === ip || socketIp === '::ffff:' + ip)

    if (isTrustedProxy) {
      const forwarded = req.headers['x-forwarded-for']
      if (forwarded) {
        return forwarded.split(',')[0].trim()
      }

      const realIp = req.headers['x-real-ip']
      if (realIp) {
        return realIp
      }
    }

    return socketIp
  }

  /**
   * Get rate limiter stats
   * @returns {Promise<Object>} Stats
   */
  async getRateLimitStats() {
    return await this.rateLimiter.stats()
  }

  /**
   * Get challenge manager stats
   * @returns {Promise<Object>} Stats
   */
  async getChallengeStats() {
    return await this.challengeManager.stats()
  }

  /**
   * Check if user has access to a repository.
   *
   * Resolution order:
   * 1. Read action → ALLOW (federation = full read access)
   * 2. User is global admin → ALLOW
   * 3. User is repo owner → ALLOW
   * 4. Direct repo grant via NamespaceRegistry → ALLOW
   * 5. Group grant (groups containing repo) via NamespaceRegistry → ALLOW
   * 6. Server grant via NamespaceRegistry → ALLOW
   * 7. Legacy repo.collaborators[] (backward compat) → ALLOW
   * 8. DENY
   *
   * @param {Object|null} auth - Authentication result (null for anonymous)
   * @param {Object} repo - Repository object with visibility, owner, collaborators
   * @param {string} action - Action to perform ('read', 'write', 'admin')
   * @param {Object} [options] - Additional context
   * @param {Object} [options.namespaceRegistry] - NamespaceRegistry for grant checks
   * @param {string} [options.serverKey] - Server key for server-level grant checks
   * @returns {boolean|Promise<boolean>} Whether user has access
   */
  hasRepoAccess(auth, repo, action = 'read', options = {}) {
    if (!repo) return false

    // 1. Read action → ALLOW (federation = full read access)
    if (action === 'read') return true

    // 2. No auth → DENY for write actions
    if (!auth) return false

    // 3. Global admin → ALLOW
    if (auth.permissions && auth.permissions.admin) return true

    // 4. Repo owner → ALLOW
    if (repo.owner && repo.owner === auth.userId) return true

    // 5-7. Check grants (async) — if registry available
    if (options.namespaceRegistry && auth.userId) {
      return this._checkGrantAccess(auth, repo, action, options)
    }

    // 8. Legacy collaborators check (backward compat)
    if (repo.collaborators && auth.userId) {
      const collab = repo.collaborators.find(c => c.userId === auth.userId)
      if (collab) {
        if (action === 'write' && (collab.permissions.includes('write') || collab.permissions.includes('admin'))) {
          return true
        }
        if (action === 'admin' && collab.permissions.includes('admin')) {
          return true
        }
      }
    }

    // Fall back to global permissions
    return !!(auth.permissions && auth.permissions[action])
  }

  /**
   * Check grant-based access via NamespaceRegistry
   * @private
   */
  async _checkGrantAccess(auth, repo, action, options) {
    const registry = options.namespaceRegistry
    const permMap = { write: 'write', admin: 'owner' }
    const requiredPerm = permMap[action] || 'write'

    const hasGrant = await registry.checkAccess({
      userId: auth.userId,
      serverKey: options.serverKey || null,
      repoName: repo.name,
      requiredPermission: requiredPerm
    })

    if (hasGrant) return true

    // Legacy collaborators check (backward compat)
    if (repo.collaborators && auth.userId) {
      const collab = repo.collaborators.find(c => c.userId === auth.userId)
      if (collab) {
        if (action === 'write' && (collab.permissions.includes('write') || collab.permissions.includes('admin'))) {
          return true
        }
        if (action === 'admin' && collab.permissions.includes('admin')) {
          return true
        }
      }
    }

    return !!(auth.permissions && auth.permissions[action])
  }

  /**
   * Cleanup resources
   */
  destroy() {
    this.challengeManager.destroy()
    this.rateLimiter.destroy()
  }
}

/**
 * Authorization levels
 */
export const Permissions = {
  READ: 'read',
  WRITE: 'write',
  ADMIN: 'admin'
}

/**
 * Helper to send auth error response
 */
export function sendAuthError(res, message = 'Unauthorized', statusCode = 401) {
  res.statusCode = statusCode
  res.setHeader('WWW-Authenticate', 'Bearer realm="Pear Git"')
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({
    error: message,
    hint: 'Use Authorization: Bearer <token>, X-Wirecore-PublicKey header, or ?token=<token>'
  }))
}