OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / access-token.js

173 lines · javascript ref:
/**
 * Personal access tokens (PATs) for classic-git HTTPS push.
 *
 * See spec/flows/classic-git-auth.md. A PAT is NOT a new store — it is an
 * `access-token` identity in the IdentityRegistry, linked to a User exactly
 * like an ssh-key or browser identity. The whole point is the unified ACL:
 * `canPushGit` resolves a token → User → perms, the same User a browser
 * session or SSH key resolves to.
 *
 * Storage model:
 *
 *   identityId : tok:<sha256(secret)>     ← the hash IS the lookup key
 *   method     : 'access-token'
 *   credentials: { name, scope, createdAt, expiresAt|null }
 *
 * The raw secret (`pgv3_<48 hex>`) is returned by mintToken ONCE and is never
 * persisted — only sha256(secret) is. There is no way to recover it; a lost
 * token is revoked + re-minted. `getUserByIdentity('tok:'+hash)` resolves the
 * owning User; revocation is `revokeIdentity('tok:'+hash)` (soft — never a
 * hard delete, consistent with the registry's revoke model).
 *
 * SECURITY: getUserByIdentity does NOT check `revoked`/expiry (it only walks
 * the user-identity link). resolveToken below is the ONLY safe resolver — it
 * re-reads the identity record and enforces revoked + expiry before returning
 * a user. Never resolve a token by calling getUserByIdentity directly.
 */

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

const TOKEN_PREFIX = 'pgv3_'
const SECRET_BYTES = 24 // 48 hex chars of entropy
const VALID_SCOPES = new Set(['read', 'write'])

/**
 * sha256(secret) as lowercase hex. The lookup is a hash-table get on the
 * digest, so there's no string-compare timing leak against a stored secret
 * (we never store the secret, only its digest).
 */
function hashSecret (secret) {
  return b4a.toString(crypto.hash(b4a.from(secret, 'utf8')), 'hex')
}

/** The IdentityRegistry identityId for a raw secret. */
function identityIdFor (secret) {
  return 'tok:' + hashSecret(secret)
}

/**
 * Mint a fresh PAT for a user. Returns the raw secret ONCE — caller must show
 * it to the user immediately and never store it.
 *
 * @param {object} registry - IdentityRegistry
 * @param {string} userId
 * @param {object} [opts]
 * @param {string} [opts.name='token']     - human label
 * @param {'read'|'write'} [opts.scope='write']
 * @param {number|null} [opts.expiresAt=null] - epoch ms, or null for no expiry
 * @param {number} [opts.now] - injectable clock for tests
 * @returns {Promise<{ secret, identityId, name, scope, createdAt, expiresAt }>}
 */
export async function mintToken (registry, userId, opts = {}) {
  if (!registry) throw new Error('mintToken requires a registry')
  if (!userId) throw new Error('mintToken requires a userId')

  const scope = opts.scope || 'write'
  if (!VALID_SCOPES.has(scope)) throw new Error('invalid token scope: ' + scope)

  const name = (opts.name && String(opts.name).slice(0, 64)) || 'token'
  const expiresAt = (typeof opts.expiresAt === 'number' && opts.expiresAt > 0)
    ? opts.expiresAt
    : null
  const createdAt = typeof opts.now === 'number' ? opts.now : Date.now()

  const secret = TOKEN_PREFIX + b4a.toString(crypto.randomBytes(SECRET_BYTES), 'hex')
  const identityId = 'tok:' + hashSecret(secret)

  // register THEN link — linkIdentityToUser reads the identity record back,
  // so it must already exist in the registry.
  await registry.registerIdentity({
    identityId,
    method: 'access-token',
    credentials: { name, scope, createdAt, expiresAt }
  })
  await registry.linkIdentityToUser(identityId, userId, { verified: true })

  return { secret, identityId, name, scope, createdAt, expiresAt }
}

/**
 * Resolve a raw secret to its owning user, enforcing revoked + expiry.
 * The ONLY safe way to authenticate a token.
 *
 * @param {object} registry
 * @param {string} secret - the `pgv3_…` value presented by the client
 * @param {object} [opts]
 * @param {number} [opts.now] - injectable clock for tests
 * @returns {Promise<{ user, scope, identityId } | null>} null if unknown,
 *          malformed, revoked, expired, or unlinked.
 */
export async function resolveToken (registry, secret, opts = {}) {
  if (!registry) return null
  if (typeof secret !== 'string' || !secret.startsWith(TOKEN_PREFIX)) return null

  const identityId = identityIdFor(secret)
  const identity = await registry.getIdentity(identityId).catch(() => null)
  if (!identity || identity.method !== 'access-token') return null

  // Revocation: the registry keeps a SEPARATE `revoked:<id>` view index; the
  // record returned by getIdentity does NOT carry the flag, so we must consult
  // isRevoked (authoritative). Checking identity.revoked alone silently misses
  // every revocation against the real Autobase-backed registry.
  if (typeof registry.isRevoked === 'function') {
    if (await registry.isRevoked(identityId).catch(() => false)) return null
  } else if (identity.revoked) {
    return null
  }

  const cred = identity.credentials || {}
  const now = typeof opts.now === 'number' ? opts.now : Date.now()
  if (cred.expiresAt && cred.expiresAt < now) return null

  const user = await registry.getUserByIdentity(identityId).catch(() => null)
  if (!user) return null

  return { user, scope: cred.scope || 'write', identityId }
}

/**
 * Revoke a token. Accepts either the raw secret or the `tok:<hash>`
 * identityId (the latter is what the management UI/CLI holds — it never sees
 * the secret again). Soft revoke (revokeIdentity), never a hard delete.
 */
export async function revokeToken (registry, secretOrIdentityId) {
  if (!registry || !secretOrIdentityId) return false
  const identityId = secretOrIdentityId.startsWith('tok:')
    ? secretOrIdentityId
    : identityIdFor(secretOrIdentityId)
  await registry.revokeIdentity(identityId, { reason: 'token-revoked' })
  return true
}

/**
 * List a user's access tokens — safe metadata only (never the secret, which
 * isn't stored anyway). Skips revoked tokens.
 *
 * @returns {Promise<Array<{ identityId, name, scope, createdAt, expiresAt }>>}
 */
export async function listTokens (registry, userId) {
  if (!registry || !userId) return []
  const bundle = await registry.getUserWithIdentities(userId).catch(() => null)
  if (!bundle || !bundle.identities) return []
  const out = []
  for (const i of bundle.identities) {
    if (i.method !== 'access-token') continue
    // Same revocation caveat as resolveToken — use isRevoked, not i.revoked.
    let revoked = !!i.revoked
    if (typeof registry.isRevoked === 'function') {
      revoked = await registry.isRevoked(i.identityId).catch(() => revoked)
    }
    if (revoked) continue
    const c = i.credentials || {}
    out.push({
      identityId: i.identityId,
      name: c.name || 'token',
      scope: c.scope || 'write',
      createdAt: c.createdAt || null,
      expiresAt: c.expiresAt || null
    })
  }
  return out
}