OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / auth.js

377 lines · javascript ref:
import { IdentityRegistry } from './auth/identity-registry.js'
import { WebIdentityProvider } from './auth/providers/web-identity.js'
import { BearerTokenProvider } from './auth/providers/bearer-token.js'
// KeetIdentityProvider deferred to v2 (requires keet-identity-key)
// import { KeetIdentityProvider } from './auth/providers/keet-identity.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
 *
 * 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']

    // Initialize identity registry
    this.identityRegistry = new IdentityRegistry(this.hyperbee)

    // 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 },
        'notes': { 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 auth providers (no SSH for notes)
    this.providers = [
      new WebIdentityProvider(this.identityRegistry, this.challengeManager),
      // new KeetIdentityProvider(this.identityRegistry),  // v2
      new BearerTokenProvider(this.identityRegistry)
    ]

    // 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
    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: 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) {
              return {
                userId: newUser.userId,
                identityId: authResult.identityId,
                method: authResult.method,
                permissions: this.formatUserPermissions(newUser, null),
                user: newUser,
                verified: true
              }
            }
          }

          if (!user) {
            continue
          }

          // Auto-promote to admin if no admin exists (bootstrap case)
          const hasAdmin = await this.identityRegistry._hasAnyAdmin()
          if (!hasAdmin && user.globalPermissions && !user.globalPermissions.includes('admin')) {
            await this.identityRegistry.upgradePermissions(user.userId, '*', ['read', 'write', 'admin'])
            // Re-fetch upgraded user
            const upgraded = await this.identityRegistry.getUser(user.userId)
            if (upgraded) user = upgraded
          }

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

    return null
  }

  /**
   * Get permissions for a user
   * @param {Object} user - User object
   * @param {string} notebookName - Notebook name
   * @returns {Object} Formatted permissions
   */
  formatUserPermissions(user, notebookName) {
    // Check notebook-specific permissions
    const notebookPerms = user.notebooks.find(r => r.name === notebookName || r.name === '*')

    if (notebookPerms) {
      return {
        read: notebookPerms.permissions.includes('read') || notebookPerms.permissions.includes('admin'),
        write: notebookPerms.permissions.includes('write') || notebookPerms.permissions.includes('admin'),
        admin: notebookPerms.permissions.includes('admin'),
        notebooks: [notebookPerms.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'),
      notebooks: ['*']
    }
  }

  /**
   * 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'),
      notebooks: authResult.notebooks
    }
  }

  /**
   * Check if auth has permission for action on notebook
   * @param {Object} auth - Auth result from authenticate()
   * @param {string} notebookName - Notebook name
   * @param {string} action - 'read', 'write', or 'admin'
   * @returns {boolean}
   */
  hasNotebookAccess(auth, notebookName, action = 'read') {
    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 notebook access
    if (notebookName) {
      return perms.notebooks.includes('*') || perms.notebooks.includes(notebookName)
    }

    return true
  }

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

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

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

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

  // ==================== TOKEN MANAGEMENT ====================

  async createToken(options) {
    return this.bearerProvider.createToken(options)
  }

  async validateToken(token) {
    return this.bearerProvider.validateToken(token)
  }

  async revokeToken(tokenHash) {
    return this.bearerProvider.revokeToken(tokenHash)
  }

  async listAllTokens() {
    return this.bearerProvider.listAllTokens()
  }

  async getUserTokens(username) {
    return this.bearerProvider.getUserTokens(username)
  }

  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'],
      notebooks: ['*']
    })
  }

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

  // ==================== IDENTITY METHODS ====================

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

  async upgradeUserPermissions(userId, notebookName, permissions) {
    return this.identityRegistry.upgradePermissions(userId, notebookName, permissions)
  }

  async generateChallenge(options = {}) {
    return await this.challengeManager.generate(options)
  }

  async verifyChallenge(challengeId, challenge) {
    return await this.challengeManager.verify(challengeId, challenge)
  }

  async revokeIdentity(identityId, options = {}) {
    return this.identityRegistry.revokeIdentity(identityId, options)
  }

  async restoreIdentity(identityId) {
    return this.identityRegistry.restoreIdentity(identityId)
  }

  async listRevokedIdentities() {
    return this.identityRegistry.listRevokedIdentities()
  }

  getClientIp(req) {
    const socketIp = req.socket?.remoteAddress || 'unknown'

    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
  }

  async getRateLimitStats() {
    return await this.rateLimiter.stats()
  }

  async getChallengeStats() {
    return await this.challengeManager.stats()
  }

  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="Hypernote"')
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({
    error: message,
    hint: 'Use Authorization: Bearer <token> or X-Wirecore-PublicKey header'
  }))
}