OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / access-tokens.js

135 lines · javascript ref:
/**
 * Personal access token RPC handlers (classic-git-auth Phase 1).
 *
 *   create-access-token { name?, scope?, ttlDays?, userId? }
 *     Mints a PAT for the CALLER's own user (HTTP: resolved from session;
 *     UDS: must pass userId). Returns the raw secret ONCE — it is never
 *     stored or recoverable. Any authenticated user may mint a token for
 *     themselves; the token can never exceed the user's own perms, and a
 *     read-scoped token can't push at all (enforced in canPushGit).
 *
 *   list-access-tokens { userId? }
 *     Metadata only (never the secret). Caller's own tokens by default.
 *
 *   revoke-access-token { identityId }
 *     Soft revoke (revokeIdentity). Caller may revoke only their OWN tokens;
 *     UDS callers (local operator) may revoke any.
 *
 * Tokens are `access-token` identities in the IdentityRegistry — see
 * lib/auth/access-token.js and spec/flows/classic-git-auth.md.
 */

import { AuthError } from '../auth/check.js'
import { mintToken, listTokens, revokeToken } from '../auth/access-token.js'
import { findOwnerUserId } from '../auth/ssh-identity.js'

const DAY_MS = 24 * 60 * 60 * 1000
const DEFAULT_TTL_DAYS = 90

export function accessTokenHandlers (svc) {
  const registry = () => svc.identityRegistry

  async function resolveCallerUserId (ctx) {
    if (!ctx || !ctx.identity || !ctx.identity.publicKey) return null
    const user = await registry().getUserByIdentity(ctx.identity.publicKey).catch(() => null)
    return user ? user.userId : null
  }

  // Resolve a UDS caller to the OWNER user. The local operator on the UDS
  // socket IS the daemon owner — transport:'uds' is already fully trusted for
  // authz; this just attributes the operation to a concrete User. The
  // hyperident root is the intrinsic owner (see auth/envelope-ctx.js); fall
  // back to the registry's admin user. Explicit { userId } wins (operator
  // acting on behalf of another user).
  async function udsOwnerUserId (params) {
    if (params && params.userId) return params.userId
    const reg = registry()
    const rootHex = svc.identity && svc.identity.rootPubkeyHex
    if (rootHex) {
      const u = await reg.getUserByIdentity(rootHex).catch(() => null)
      if (u) return u.userId
    }
    const owner = await findOwnerUserId(reg)
    if (!owner) {
      throw new Error('no owner user yet — sign in via the web once to bootstrap the owner identity')
    }
    return owner
  }

  // HTTP → caller's own user (from session); UDS → the owner (local operator).
  async function ownUserId (ctx, params) {
    if (ctx && ctx.transport === 'uds') return udsOwnerUserId(params)
    const userId = await resolveCallerUserId(ctx)
    if (!userId) throw new AuthError('not authenticated')
    return userId
  }

  // Resolve a requested TTL to an absolute expiry (epoch ms) or null.
  //   expiresAt (number)         → used verbatim
  //   ttlDays   (number > 0)     → now + ttlDays
  //   ttlDays   === 0            → no expiry (explicit opt-in)
  //   (neither)                  → DEFAULT_TTL_DAYS
  function resolveExpiry (params, now) {
    if (params && typeof params.expiresAt === 'number') return params.expiresAt
    if (params && typeof params.ttlDays === 'number') {
      return params.ttlDays > 0 ? now + params.ttlDays * DAY_MS : null
    }
    return now + DEFAULT_TTL_DAYS * DAY_MS
  }

  return [
    {
      name: 'create-access-token',
      fn: async (ctx, params = {}) => {
        const userId = await ownUserId(ctx, params)
        const now = Date.now()
        const minted = await mintToken(registry(), userId, {
          name: params.name,
          scope: params.scope || 'write',
          expiresAt: resolveExpiry(params, now),
          now
        })
        // Return the secret ONCE. Caller (UI/CLI) must surface it immediately.
        return {
          secret: minted.secret,
          identityId: minted.identityId,
          name: minted.name,
          scope: minted.scope,
          createdAt: minted.createdAt,
          expiresAt: minted.expiresAt
        }
      }
    },

    {
      name: 'list-access-tokens',
      fn: async (ctx, params = {}) => {
        return { tokens: await listTokens(registry(), await ownUserId(ctx, params)) }
      }
    },

    {
      name: 'revoke-access-token',
      fn: async (ctx, params = {}) => {
        const identityId = params && params.identityId
        if (!identityId || identityId.indexOf('tok:') !== 0) {
          throw new Error('revoke-access-token requires { identityId: "tok:…" }')
        }

        // Ownership: a token may be revoked by its owner, or by any UDS caller
        // (local operator). Never let one HTTP user revoke another's token.
        if (!(ctx && ctx.transport === 'uds')) {
          const callerId = await ownUserId(ctx, params)
          const owner = await registry().getUserByIdentity(identityId).catch(() => null)
          if (!owner || owner.userId !== callerId) {
            throw new AuthError('cannot revoke a token you do not own', 403)
          }
        }

        await revokeToken(registry(), identityId)
        return { revoked: true, identityId }
      }
    }
  ]
}