OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / sessions.js

109 lines · javascript ref:
// In-memory session store with sliding renewal.
//
// PORTED from pear-git-v3 `lib/auth/sessions.js` (the flagship public-web p2p app). favela had the
// same need and a weaker answer: a bare `Map` inside FavelaAuth with a FIXED expiry, no renewal and
// no sweeper — so an abandoned session sat in memory until the process died, and an ACTIVE user was
// logged out the instant the fixed window elapsed.
//
// One deliberate difference from pgv3: it stores a bare `publicKey`; favela's identity is a PAIR —
// the DID (the account) and the browser sub-key delegated to it (the device) — so the payload is a
// whole identity object. Both are needed downstream: the DID for authorisation, the sub-key for the
// revocation check.
//
// NOT persisted across restarts, and that is correct rather than lazy: the browser keeps its sub-key
// AND its delegation in localStorage (`/api/auth/pair` returns the delegation for exactly this
// reason), so re-authentication after a restart is silent. Persisting bearer tokens would add a
// durable secret store to a process whose whole design point is that it holds no keys.

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

// 30 days — favela's existing SESSION_TTL_MS. pgv3 uses 24h; keeping favela's value means this port
// changes session LIFETIME for nobody, only how it is renewed and reclaimed.
export const DEFAULT_TTL = 30 * 24 * 60 * 60 * 1000
export const DEFAULT_CLEANUP = 60 * 1000 // 1 min

export class SessionStore {
  constructor (opts = {}) {
    this._ttl = opts.ttl || DEFAULT_TTL
    this._sessions = new Map() // token → { identity, expiresAt }
    this._cleanupInterval = setInterval(() => this._cleanup(), opts.cleanupInterval || DEFAULT_CLEANUP)
    // Unref'd: a session sweeper must never be the reason a process stays alive.
    if (this._cleanupInterval.unref) this._cleanupInterval.unref()
  }

  /**
   * Mint a fresh session. Returns the bearer token (64-char hex).
   * @param {object} identity - e.g. { did, subKey }
   */
  create (identity) {
    const token = b4a.toString(crypto.randomBytes(32), 'hex')
    this._sessions.set(token, { identity, expiresAt: Date.now() + this._ttl })
    return token
  }

  /**
   * Look up a session. Slides the expiry forward on every hit, so an active user stays signed in and
   * only genuine IDLE time expires them.
   * @returns {{ identity: object, 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
    }
    entry.expiresAt = Date.now() + this._ttl
    return entry
  }

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

  /** Number of sessions held (including any not yet swept). */
  get size () {
    return this._sessions.size
  }

  /**
   * Diagnostics only. The token IS the credential, so the listing carries a short PREFIX — enough to
   * correlate with a log line, useless to replay — never the token itself.
   */
  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),
        did: entry.identity && entry.identity.did ? entry.identity.did : null,
        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 sweeper and drop every session. */
  close () {
    if (this._cleanupInterval) {
      clearInterval(this._cleanupInterval)
      this._cleanupInterval = null
    }
    this._sessions.clear()
  }
}

export default SessionStore