/**
* 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
}