OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / sessions.js

112 lines · javascript ref:
/**
 * In-memory session store with sliding renewal.
 *
 * Sessions are bearer tokens (random 32-byte hex). Each session is mapped
 * to a publicKey and an expiresAt. Lookup extends expiresAt — sliding
 * window renewal — so active users stay logged in indefinitely.
 *
 * Sessions are NOT persisted across daemon restarts. The user re-authenticates
 * after a restart, which is fine because the browser still has its keypair
 * in localStorage and the auth flow is silent.
 *
 * This is intentionally simpler than v1's Hyperbee-persisted token registry
 * (packages/hyper-identity/lib/auth/identity-registry.js, ~800 LOC). v3 only
 * needs in-memory bearer tokens — re-auth on restart is cheap because the
 * browser keypair survives in localStorage.
 */

import crypto from 'hypercore-crypto'
import b4a from 'b4a'

const DEFAULT_TTL = 24 * 60 * 60 * 1000 // 24h
const DEFAULT_CLEANUP = 60 * 1000 // 1 min

export class SessionStore {
  constructor (opts = {}) {
    this._ttl = opts.ttl || DEFAULT_TTL
    this._sessions = new Map() // token → { publicKey, expiresAt }
    this._cleanupInterval = setInterval(() => this._cleanup(), opts.cleanupInterval || DEFAULT_CLEANUP)
    if (this._cleanupInterval.unref) this._cleanupInterval.unref()
  }

  /**
   * Mint a fresh session for a public key. Returns the bearer token.
   * @param {string} publicKey - hex string
   * @returns {string} 64-char hex token
   */
  create (publicKey) {
    const token = b4a.toString(crypto.randomBytes(32), 'hex')
    this._sessions.set(token, {
      publicKey,
      expiresAt: Date.now() + this._ttl
    })
    return token
  }

  /**
   * Look up a session by token. Slides the expiry forward on every hit.
   * @param {string} token
   * @returns {{ publicKey: string, expiresAt: number } | null}
   */
  get (token) {
    if (!token) return null
    const entry = this._sessions.get(token)
    if (!entry) return null
    if (entry.expiresAt < Date.now()) {
      this._sessions.delete(token)
      return null
    }
    // Sliding renewal — extend on each access
    entry.expiresAt = Date.now() + this._ttl
    return entry
  }

  /**
   * Revoke a session. Returns true if it existed.
   */
  revoke (token) {
    if (!token) return false
    return this._sessions.delete(token)
  }

  /**
   * Number of active (unexpired) sessions.
   */
  get size () {
    return this._sessions.size
  }

  /**
   * List active sessions (for diagnostics — never expose secrets).
   */
  list () {
    const now = Date.now()
    const out = []
    for (const [token, entry] of this._sessions) {
      if (entry.expiresAt < now) continue
      out.push({
        tokenPrefix: token.slice(0, 8),
        publicKey: entry.publicKey,
        expiresAt: entry.expiresAt
      })
    }
    return out
  }

  _cleanup () {
    const now = Date.now()
    for (const [token, entry] of this._sessions) {
      if (entry.expiresAt < now) this._sessions.delete(token)
    }
  }

  /** Stop the cleanup timer and drop all sessions. */
  close () {
    if (this._cleanupInterval) {
      clearInterval(this._cleanupInterval)
      this._cleanupInterval = null
    }
    this._sessions.clear()
  }
}