OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth.js

518 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'
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'

/**
 * AuthManager — Generic multi-method authentication and authorization
 *
 * Supports:
 * - Web Identity (ANProto Ed25519 challenge-response)
 * - Keet Identity (BIP39 mnemonic proof chains)
 * - Bearer Tokens (API tokens with embedded permissions)
 * - SSH Keys (Ed25519 + RSA signature verification)
 *
 * Permission model is injectable via checkPermission callback.
 * Uses 'resources' as the generic term (not repos, notebooks, etc).
 */
export class AuthManager {
  constructor (options = {}) {
    this.db = options.db || null // Direct-write Hyperbee for identity/token storage
    this.trustedProxies = options.trustedProxies || ['127.0.0.1', '::1', '::ffff:127.0.0.1']

    // Injectable permission check: (auth, action, resourceName) => boolean
    // Default: check auth.permissions object
    this.checkPermission = options.checkPermission || null

    // Identity registry (Autobase-backed)
    this.identityRegistry = options.identityRegistry || null

    // Challenge manager
    this.challengeManager = new ChallengeManager({
      db: this.db,
      ttl: options.challengeTTL || 5 * 60 * 1000,
      cacheTimeout: options.challengeCacheTimeout || 30 * 1000
    })

    // Rate limiter
    this.rateLimiter = new RateLimiter({
      db: this.db,
      limits: options.rateLimits || {
        auth: { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 },
        api: { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 },
        'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 }
      },
      cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000
    })

    // Auth providers
    this.providers = []

    // Register default providers based on what's available
    if (this.identityRegistry) {
      this.providers.push(
        new WebIdentityProvider(this.identityRegistry, this.challengeManager, {
          autoRegister: options.autoRegister !== false // default: true
        })
      )

      if (options.IdentityKey) {
        this.providers.push(
          new KeetIdentityProvider(this.identityRegistry, { IdentityKey: options.IdentityKey })
        )
      }

      this.providers.push(
        new BearerTokenProvider(this.identityRegistry, { db: this.db })
      )

      if (options.enableSSH !== false) {
        this.providers.push(
          new SSHKeyProvider(this.identityRegistry, this.challengeManager)
        )
      }
    }

    // Get bearer token provider reference
    this.bearerProvider = this.providers.find(p => p.name === 'bearer-token') || null
  }

  /**
   * Add a custom auth provider
   * @param {BaseProvider} provider
   */
  addProvider (provider) {
    this.providers.push(provider)
  }

  /**
   * 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 = {}) {
    // Rate limiting
    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
    for (const provider of this.providers) {
      const credentials = await provider.extractCredentials(req)
      if (!credentials) continue

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

        // Bearer tokens have permissions embedded
        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
        let user = await this.identityRegistry.getUserByIdentity(authResult.identityId)

        if (!user && authResult.method === 'web-identity') {
          // Retry — web identity auto-registers
          user = await this.identityRegistry.getUserByIdentity(authResult.identityId)
        }

        if (!user) continue

        // Auto-promote to admin if no admin exists (bootstrap)
        const hasAdmin = await this.identityRegistry._hasAnyAdmin()
        if (!hasAdmin && user.globalPermissions && !user.globalPermissions.includes('admin')) {
          await this.identityRegistry.upgradePermissions(user.userId, '*', ['read', 'write', 'admin'])
          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) {
        console.error(`[AuthManager] Provider ${provider.name} error:`, err.message)
        continue
      }
    }

    return null
  }

  /**
   * Generate a challenge for challenge-response auth
   * @param {Object} opts - { publicKey?, metadata? }
   * @returns {Promise<Object>} { challengeId, challenge, expiresAt }
   */
  async generateChallenge (opts = {}) {
    return this.challengeManager.generate(opts)
  }

  /**
   * Format user permissions
   * Uses user.resources (generic) instead of application-specific terms
   * @param {Object} user - User object
   * @param {string} resourceName - Resource name (or null for global)
   * @returns {Object} { read, write, admin, resources }
   */
  formatUserPermissions (user, resourceName) {
    // Resource-specific permissions, SPECIFIC BEFORE WILDCARD.
    //
    // This used to be a single `.find(r => r.name === resourceName || r.name
    // === '*')`, which returns the FIRST match — so for a user holding
    // [{'*': read}, {'secret': admin}] a lookup of 'secret' matched '*' and
    // returned read-only. Named grants were silently shadowed by the wildcard
    // depending on array ORDER, which made per-resource permissions unreliable
    // for any purpose, and made "readable by everyone except these named
    // people" inexpressible.
    //
    // Precedence is now explicit: an exact-name entry wins; '*' applies only
    // when the resource has no entry of its own. That is what lets '*' mean
    // "whatever is public" while a named grant still opens one private
    // resource to one user.
    const resources = user.resources || []
    const exact = resourceName ? resources.find(r => r.name === resourceName) : null
    const wildcard = resources.find(r => r.name === '*')
    const resourcePerms = exact || wildcard

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

  /**
   * Format bearer token 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'),
      resources: authResult.resources || ['*']
    }
  }

  /**
   * Check if auth has permission for action on resource
   * Uses injectable checkPermission callback if provided.
   * @param {Object} auth - Auth result from authenticate()
   * @param {string} resourceName - Resource name
   * @param {string} action - 'read', 'write', or 'admin'
   * @returns {boolean}
   */
  hasResourceAccess (auth, resourceName, action = 'read') {
    if (!auth || !auth.verified) return false

    // Use custom permission check if provided
    if (this.checkPermission) {
      return this.checkPermission(auth, action, resourceName)
    }

    // Default permission check
    const perms = auth.permissions
    if (perms.admin) return true
    if (!perms[action]) return false

    if (resourceName) {
      return perms.resources.includes('*') || perms.resources.includes(resourceName)
    }
    return true
  }

  /**
   * Middleware: authenticate and authorize request
   * @param {Object} req - HTTP request
   * @param {string} action - Required action
   * @param {string} resourceName - Resource name
   * @returns {Promise<Object>} { authorized, auth, reason }
   */
  async authorizeRequest (req, action, resourceName = null) {
    const auth = await this.authenticate(req)
    if (!auth) {
      return { authorized: false, auth: null, reason: 'Not authenticated' }
    }
    if (!this.hasResourceAccess(auth, resourceName, action)) {
      return { authorized: false, auth, reason: 'Insufficient permissions' }
    }
    return { authorized: true, auth, reason: null }
  }

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

  async createToken (options) {
    if (!this.bearerProvider) throw new Error('Bearer token provider not configured')
    return this.bearerProvider.createToken(options)
  }

  async validateToken (token) {
    if (!this.bearerProvider) return null
    return this.bearerProvider.validateToken(token)
  }

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

  async listAllTokens () {
    if (!this.bearerProvider) return []
    return this.bearerProvider.listAllTokens()
  }

  async getUserTokens (username) {
    if (!this.bearerProvider) return []
    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'],
      resources: ['*']
    })
  }

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

  // ==================== ACCOUNT MERGE PROTOCOL ====================

  /**
   * Initiate an account merge. The primary user (caller) wants to merge
   * the target user (identified by publicKey) into their account.
   *
   * Returns a merge request with a challenge that the target must sign.
   *
   * @param {string} primaryUserId - The user initiating the merge
   * @param {string} targetPublicKey - Public key of the account to merge in
   * @returns {Promise<Object>} { mergeId, challenge, expiresAt, primaryUserId, targetPublicKey }
   */
  async initiateMerge (primaryUserId, targetPublicKey) {
    // Verify primary user exists
    const primaryUser = await this.identityRegistry.getUser(primaryUserId)
    if (!primaryUser) throw new Error('Primary user not found')

    // Verify target identity exists and has a user
    const targetIdentity = await this.identityRegistry.getIdentityByPublicKey(targetPublicKey)
    if (!targetIdentity || !targetIdentity.userId) {
      throw new Error('Target identity not found or not linked to a user')
    }

    if (targetIdentity.userId === primaryUserId) {
      throw new Error('Cannot merge account with itself')
    }

    // Generate a merge challenge
    const mergeChallenge = await this.challengeManager.generate({
      metadata: {
        type: 'account-merge',
        primaryUserId,
        targetPublicKey,
        targetUserId: targetIdentity.userId
      }
    })

    // Store merge request in db
    if (this.db) {
      await this.db.put(`merge:${mergeChallenge.challengeId}`, {
        mergeId: mergeChallenge.challengeId,
        primaryUserId,
        targetPublicKey,
        targetUserId: targetIdentity.userId,
        challenge: mergeChallenge.challenge,
        expiresAt: mergeChallenge.expiresAt,
        status: 'pending'
      })
    }

    return {
      mergeId: mergeChallenge.challengeId,
      challenge: mergeChallenge.challenge,
      expiresAt: mergeChallenge.expiresAt,
      primaryUserId,
      targetPublicKey
    }
  }

  /**
   * Confirm a merge by providing the target user's signature.
   * The target must sign the merge challenge with their private key.
   *
   * @param {string} mergeId - Merge request ID
   * @param {string} signature - Target's signature of the challenge
   * @returns {Promise<Object>} Merge result
   */
  async confirmMerge (mergeId, signature) {
    if (!this.db) throw new Error('No db configured for merge')

    const entry = await this.db.get(`merge:${mergeId}`)
    if (!entry) throw new Error('Merge request not found')

    const merge = entry.value
    if (merge.status !== 'pending') throw new Error('Merge already completed or expired')
    if (merge.expiresAt < Date.now()) throw new Error('Merge request expired')

    // Verify the signature is from the target (canonical ANProto format)
    const { ANProto } = await import('./anproto.js')
    const result = ANProto.verify(merge.challenge, signature)
    if (!result.valid) throw new Error('Invalid signature — ' + (result.error || 'verification failed'))
    if (result.publicKey !== merge.targetPublicKey) throw new Error('Signature is not from the target account')

    // Execute the merge
    await this.identityRegistry.mergeAccounts(merge.primaryUserId, merge.targetUserId)

    // Mark merge as complete
    merge.status = 'completed'
    merge.completedAt = Date.now()
    await this.db.put(`merge:${mergeId}`, merge)

    return {
      merged: true,
      primaryUserId: merge.primaryUserId,
      secondaryUserId: merge.targetUserId
    }
  }

  /**
   * Get merge request status
   * @param {string} mergeId
   * @returns {Promise<Object|null>}
   */
  async getMerge (mergeId) {
    if (!this.db) return null
    const entry = await this.db.get(`merge:${mergeId}`)
    if (!entry) return null
    // Don't expose the challenge value
    const { challenge, ...safe } = entry.value
    return safe
  }

  // ==================== 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, resourceName, permissions) {
    return this.identityRegistry.upgradePermissions(userId, resourceName, permissions)
  }

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

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

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