OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / auth / providers / bearer-token.js

239 lines · javascript ref:
import { AuthProvider } from '../base-provider.js'
import crypto from 'bare-crypto'

/**
 * BearerTokenProvider
 *
 * Implements token-based authentication:
 * - Generates secure API tokens
 * - Validates tokens from Authorization header or query params
 * - Supports per-repo or global permissions
 * - Stores tokens in Hyperbee
 */
export class BearerTokenProvider extends AuthProvider {
  constructor(registry, db) {
    super(registry)
    this.name = 'bearer-token'
    this.tokenPrefix = 'token:'
    this.userPrefix = 'user:'
    // Use raw Hyperbee for token storage (not the Autobase identity registry view)
    if (!db) throw new Error('BearerTokenProvider requires a raw Hyperbee db (not Autobase view)')
    this._db = db
  }

  /**
   * Extract token from HTTP request
   * Supports:
   * - Authorization: Bearer <token>
   * - Authorization: Token <token>
   * - X-Auth-Token header
   * - ?token=<token> query parameter
   */
  async extractCredentials(req) {
    // Check Authorization header
    const authHeader = req.headers.authorization || req.headers.Authorization
    if (authHeader) {
      const match = authHeader.match(/^(Bearer|Token)\s+(.+)$/i)
      if (match) return match[2]
    }

    // Check X-Auth-Token header
    const tokenHeader = req.headers['x-auth-token'] || req.headers['X-Auth-Token']
    if (tokenHeader) return tokenHeader

    // Check query parameter
    if (req.url) {
      try {
        const url = new URL(req.url, `http://${req.headers.host}`)
        const tokenParam = url.searchParams.get('token')
        if (tokenParam) return tokenParam
      } catch {
        // Ignore URL parsing errors
      }
    }

    return null
  }

  /**
   * Authenticate using bearer token
   * @param {string} token - Bearer token
   * @returns {Promise<Object>} { identityId, method, verified, credentials, permissions, repos }
   */
  async authenticate(token) {
    const tokenData = await this.validateToken(token)

    if (!tokenData) {
      throw new Error('Invalid or expired token')
    }

    // Check if token is revoked
    const isRevoked = await this.registry.isRevoked(tokenData.tokenHash)
    if (isRevoked) {
      throw new Error('Bearer token has been revoked')
    }

    // For bearer tokens, the identityId is the tokenHash
    return {
      identityId: tokenData.tokenHash,
      method: 'bearer-token',
      verified: true,
      credentials: {
        username: tokenData.username,
        userId: tokenData.userId || null,
        tokenHash: tokenData.tokenHash
      },
      // Bearer tokens have permissions embedded (not from User object)
      permissions: tokenData.permissions,
      repos: tokenData.repos
    }
  }

  /**
   * Validate a token and return token data if valid
   */
  async validateToken(token) {
    if (!token) return null

    const tokenHash = this.hashToken(token)

    try {
      const entry = await this._db.get(`${this.tokenPrefix}${tokenHash}`)
      if (!entry) return null

      const tokenData = entry.value

      // Check expiration
      if (tokenData.expiresAt && Date.now() > tokenData.expiresAt) {
        return null
      }

      // Update usage stats (async, don't wait)
      this.updateTokenUsage(tokenHash).catch(console.error)

      return tokenData
    } catch {
      return null
    }
  }

  /**
   * Hash a token for storage
   */
  hashToken(token) {
    return crypto.createHash('sha256').update(token).digest('hex')
  }

  /**
   * Generate a secure random token
   */
  generateToken() {
    const bytes = crypto.randomBytes(32)
    return bytes.toString('hex')
  }

  /**
   * Create a new token
   */
  async createToken(options = {}) {
    const {
      username,
      userId = null,
      description = 'API Token',
      permissions = ['read'],
      repos = ['*'],
      expiresAt = null
    } = options

    if (!username) {
      throw new Error('Username is required')
    }

    const token = this.generateToken()
    const tokenHash = this.hashToken(token)

    const tokenData = {
      tokenHash,
      username,
      userId,
      description,
      permissions,
      repos,
      createdAt: Date.now(),
      expiresAt,
      lastUsedAt: null,
      usageCount: 0
    }

    // Store in Hyperbee
    await this._db.put(`${this.tokenPrefix}${tokenHash}`, tokenData)

    // Also add to user's token list
    const userTokens = await this.getUserTokens(username)
    userTokens.push(tokenHash)
    await this._db.put(`${this.userPrefix}${username}:tokens`, userTokens)

    return {
      token,
      tokenHash,
      ...tokenData
    }
  }

  /**
   * Update token usage statistics
   */
  async updateTokenUsage(tokenHash) {
    const entry = await this._db.get(`${this.tokenPrefix}${tokenHash}`)
    if (entry) {
      entry.value.lastUsedAt = Date.now()
      entry.value.usageCount = (entry.value.usageCount || 0) + 1
      await this._db.put(`${this.tokenPrefix}${tokenHash}`, entry.value)
    }
  }

  /**
   * Get all tokens for a user
   */
  async getUserTokens(username) {
    try {
      const entry = await this._db.get(`${this.userPrefix}${username}:tokens`)
      return entry ? entry.value : []
    } catch {
      return []
    }
  }

  /**
   * Revoke a token
   */
  async revokeToken(tokenHash) {
    const entry = await this._db.get(`${this.tokenPrefix}${tokenHash}`)
    if (!entry) return false

    await this._db.del(`${this.tokenPrefix}${tokenHash}`)

    // Remove from user's token list
    const username = entry.value.username
    const userTokens = await this.getUserTokens(username)
    const filtered = userTokens.filter(t => t !== tokenHash)
    await this._db.put(`${this.userPrefix}${username}:tokens`, filtered)

    return true
  }

  /**
   * List all tokens
   */
  async listAllTokens() {
    const tokens = []
    for await (const entry of this._db.createReadStream({
      gt: this.tokenPrefix,
      lt: this.tokenPrefix + '\xff'
    })) {
      tokens.push(entry.value)
    }
    return tokens
  }
}