OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / browser-identity.js

170 lines · javascript ref:
/**
 * Browser Identity for pear-git-v3 (cookie-based auth)
 *
 * Differs from v1's hyper-identity/browser-identity.js in two ways:
 *   1. Generates keypair locally via ANProtoClient.gen() — no server endpoint
 *      (secret key never leaves the browser)
 *   2. Authentication state lives in cookies (pgv3-session), not localStorage
 *      tokens — so SSR navigation requests pick it up automatically
 *
 * The localStorage keypair is the user's "identity" for this browser. First
 * time the page loads, a fresh keypair is generated and registered with the
 * daemon (TOFU). Subsequent loads reuse the keypair and re-authenticate if
 * the cookie has expired.
 *
 * Usage (in pgv3-bootstrap.js, runs on every page):
 *   import { BrowserIdentity } from './browser-identity.js'
 *   const id = new BrowserIdentity()
 *   await id.ensureAuthenticated()
 *   // After this, the pgv3-session cookie is set and any link click /
 *   // form submit will be authenticated.
 */

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

const KEYPAIR_KEY = 'pgv3-keypair'
const V1_KEYPAIR_KEY = 'pear-git-keypair'  // v1's localStorage key — bridged at first load (M6)

export class BrowserIdentity {
  constructor (opts = {}) {
    this._baseUrl = opts.baseUrl || ''
    this._keypair = null
  }

  /**
   * Get or generate the persistent Ed25519 keypair for this browser.
   * Stored in localStorage so it survives page reloads but stays per-origin.
   *
   * M6 — v1→v3 bridge: if there's no `pgv3-keypair` but the v1 key
   * (`pear-git-keypair`) is present at the same origin, port it over.
   * Same 132-char base64 concat format (44 pub + 88 sec), so it's just a
   * key-rename. Lets users who had pear-git v1 at this origin keep their
   * identity through the upgrade without re-registering.
   */
  async getKeypair () {
    if (this._keypair) return this._keypair

    if (typeof localStorage !== 'undefined') {
      let stored = localStorage.getItem(KEYPAIR_KEY)
      if (stored && stored.length === 132) {
        this._keypair = stored
        return this._keypair
      }
      // M6 bridge — try v1's key
      const v1 = localStorage.getItem(V1_KEYPAIR_KEY)
      if (v1 && v1.length === 132) {
        localStorage.setItem(KEYPAIR_KEY, v1)
        // Leave v1 key in place (rollback safety; user can clear later)
        this._keypair = v1
        // eslint-disable-next-line no-console
        console.log('[pgv3] migrated keypair from v1 (' + V1_KEYPAIR_KEY + ' → ' + KEYPAIR_KEY + ')')
        return this._keypair
      }
    }

    // Generate locally — secret key never leaves the browser
    this._keypair = await ANProtoClient.gen()
    if (typeof localStorage !== 'undefined') {
      localStorage.setItem(KEYPAIR_KEY, this._keypair)
    }
    return this._keypair
  }

  async getPublicKeyHex () {
    const kp = await this.getKeypair()
    return ANProtoClient.getPublicKeyHex(kp)
  }

  /**
   * Make sure the browser has a valid pgv3-session cookie. If not, do the
   * full challenge-response flow with the daemon.
   *
   * The cookie is HttpOnly so we cannot read it from `document.cookie` —
   * we always have to ask the server "who am I?" and let it look at the
   * cookie via the Cookie header. This is what /api/auth/me does.
   *
   * @returns {Promise<{ publicKey: string, fresh: boolean }>}
   */
  async ensureAuthenticated () {
    // Always ask the server first. Cookies are HttpOnly so we can't peek.
    try {
      const me = await this._fetch('/api/auth/me')
      if (me && me.publicKey) {
        return { publicKey: me.publicKey, fresh: false }
      }
    } catch {
      // /api/auth/me threw — fall through to full auth
    }

    // Full auth flow
    const challengeRes = await this._fetch('/api/auth/challenge')
    const challenge = challengeRes.challenge
    if (!challenge) throw new Error('challenge missing')

    const keypair = await this.getKeypair()
    // Canonical ANProto: pubkey is embedded in the signature
    const signature = await ANProtoClient.sign(challenge, keypair)

    const verified = await this._fetch('/api/auth/verify', {
      method: 'POST',
      body: { signature, challenge }
    })

    // `fresh: true` means the browser had to run the full challenge/verify
    // ceremony. `newUser: true` means the server side created a new User
    // record as part of verify — only true on the very first sign-in for
    // this keypair. The UI uses newUser (not fresh) to decide whether to
    // show the welcome modal.
    return {
      publicKey: verified.publicKey || ANProtoClient.getPublicKey(keypair),
      fresh: true,
      newUser: !!verified.newUser
    }
  }

  /**
   * Log out — clears the cookie via the daemon.
   */
  async logout () {
    try {
      await this._fetch('/api/auth/logout', { method: 'POST' })
    } catch {}
  }

  /**
   * Forget the keypair entirely. Next ensureAuthenticated() will generate
   * a fresh identity.
   */
  forgetIdentity () {
    if (typeof localStorage !== 'undefined') {
      localStorage.removeItem(KEYPAIR_KEY)
    }
    this._keypair = null
  }

  async _fetch (path, opts = {}) {
    const url = this._baseUrl + path
    const headers = { Accept: 'application/json' }
    const init = {
      headers,
      // Same-origin includes cookies by default, but be explicit
      credentials: 'same-origin'
    }
    if (opts.method) init.method = opts.method
    if (opts.body) {
      init.method = init.method || 'POST'
      headers['Content-Type'] = 'application/json'
      init.body = JSON.stringify(opts.body)
    }

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

export default BrowserIdentity