OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / browser-identity.js

184 lines · javascript ref:
/**
 * Browser Identity — complete browser-side authentication module
 *
 * Self-contained: uses ANProtoClient for crypto, does its own fetch calls.
 * No dependency on hyper-web-ui or any other package.
 *
 * Usage:
 *   import { BrowserIdentity } from 'hyper-identity/browser'
 *   const identity = new BrowserIdentity()
 *   await identity.authenticate()
 *   const me = await identity.getMe()
 */

import { ANProtoClient } from './anproto-client.js'

const DEFAULT_KEYPAIR_KEY = 'hyper-identity-keypair'
const DEFAULT_TOKEN_KEY = 'hyper-identity-token'

export class BrowserIdentity {
  constructor (opts = {}) {
    this._keypairKey = opts.storageKey || DEFAULT_KEYPAIR_KEY
    this._tokenKey = opts.tokenKey || DEFAULT_TOKEN_KEY
    this._baseUrl = opts.baseUrl || ''
    this._keypair = null
  }

  /**
   * Get or create Ed25519 keypair (stored in localStorage)
   */
  async getKeypair () {
    if (this._keypair) return this._keypair

    const stored = typeof localStorage !== 'undefined' && localStorage.getItem(this._keypairKey)
    if (stored && stored.length === 132) {
      this._keypair = stored
      return this._keypair
    }

    // Generate keypair on server (fast, uses sodium-native)
    // Browser only stores it and uses it for signing
    const result = await this._fetch('/api/identity/generate', { method: 'POST' })
    this._keypair = result.keypair

    if (typeof localStorage !== 'undefined') {
      localStorage.setItem(this._keypairKey, this._keypair)
    }
    return this._keypair
  }

  /**
   * Get public key as hex (for API calls)
   */
  async getPublicKeyHex () {
    const kp = await this.getKeypair()
    return ANProtoClient.getPublicKeyHex(kp)
  }

  /**
   * Get public key as base64 (for display)
   */
  async getPublicKeyB64 () {
    const kp = await this.getKeypair()
    return ANProtoClient.getPublicKey(kp)
  }

  /**
   * Sign a challenge string
   */
  async sign (challenge) {
    const kp = await this.getKeypair()
    return ANProtoClient.sign(challenge, kp)
  }

  /**
   * Full authentication flow: challenge → sign → verify → token
   */
  async authenticate () {
    const publicKey = await this.getPublicKeyHex()

    // 1. Get challenge
    const challengeRes = await this._fetch('/api/auth/challenge')
    const { challengeId, challenge } = challengeRes

    // 2. Sign it
    const signature = await this.sign(challenge)

    // 3. Verify with server
    const result = await this._fetch('/api/auth/verify', {
      method: 'POST',
      body: { publicKey, signature, challengeId }
    })

    // 4. Store token
    this.storeToken(result.token)

    return result
  }

  /**
   * Store auth token
   */
  storeToken (token) {
    if (typeof localStorage !== 'undefined') {
      localStorage.setItem(this._tokenKey, token)
    }
  }

  /**
   * Get stored token
   */
  getToken () {
    if (typeof localStorage === 'undefined') return null
    return localStorage.getItem(this._tokenKey)
  }

  /**
   * Check if authenticated
   */
  isAuthenticated () {
    return !!this.getToken()
  }

  /**
   * Fetch current user from server
   */
  async getMe () {
    const token = this.getToken()
    if (!token) return null

    try {
      return await this._fetch('/api/users/me')
    } catch {
      return null
    }
  }

  /**
   * Logout — clear token (keeps keypair)
   */
  logout () {
    if (typeof localStorage !== 'undefined') {
      localStorage.removeItem(this._tokenKey)
    }
  }

  /**
   * Clear everything — keypair + token
   */
  clearIdentity () {
    this.logout()
    if (typeof localStorage !== 'undefined') {
      localStorage.removeItem(this._keypairKey)
    }
    this._keypair = null
  }

  /**
   * Internal fetch with auth header
   */
  async _fetch (path, opts = {}) {
    const url = this._baseUrl + path
    const headers = { Accept: 'application/json' }
    const token = this.getToken()
    if (token) headers.Authorization = `Bearer ${token}`

    const fetchOpts = { headers }
    if (opts.method) fetchOpts.method = opts.method
    if (opts.body) {
      fetchOpts.method = fetchOpts.method || 'POST'
      headers['Content-Type'] = 'application/json'
      fetchOpts.body = JSON.stringify(opts.body)
    }

    const res = await fetch(url, fetchOpts)
    if (!res.ok) {
      const text = await res.text()
      throw new Error(`${res.status} ${text}`)
    }
    return res.json()
  }
}

export default BrowserIdentity