/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/auth/token.mjs * Purpose : Owner Personal Access Tokens — argon2-hashed, revocable * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import argon2 from 'argon2'; import { atomicWriteJSON } from '../lib/util.mjs'; import { OWNER } from '../config.mjs'; const TOKENS_FILE = 'tokens.json'; const TOKEN_PREFIX = 'spbgit'; const TOKEN_RE = /^spbgit_([0-9a-f]{8})_([0-9a-f]{40})$/; /** * PAT store backed by `data/tokens.json`. * Token string format: `spbgit__` — only the argon2 * hash of the secret is ever written to disk. */ export class TokenStore { /** @param {string} dataDir */ constructor(dataDir) { this.path = join(dataDir, TOKENS_FILE); } /** @returns {{tokens: object[]}} */ #load() { if (!existsSync(this.path)) return { tokens: [] }; try { const parsed = JSON.parse(readFileSync(this.path, 'utf8')); if (parsed && Array.isArray(parsed.tokens)) return parsed; } catch { /* corrupted token file treated as empty (locked out until new bootstrap) */ } return { tokens: [] }; } #save(db) { atomicWriteJSON(this.path, db); } /** * Mint a new PAT. The full token string is returned exactly once. * @param {string} [label] * @returns {Promise<{token: string, record: object}>} */ async create(label = 'unnamed') { const id = randomBytes(4).toString('hex'); const secret = randomBytes(20).toString('hex'); const hash = await argon2.hash(secret, { type: argon2.argon2id }); const record = { id, label: String(label).slice(0, 80), hash, created: new Date().toISOString(), lastUsed: null, }; const db = this.#load(); db.tokens.push(record); this.#save(db); return { token: `${TOKEN_PREFIX}_${id}_${secret}`, record }; } /** * Verify a raw PAT string. Updates `lastUsed` on success. * @param {string} token * @returns {Promise} the token record (sans hash) or null */ async verify(token) { const match = TOKEN_RE.exec(String(token ?? '').trim()); if (!match) return null; const [, id, secret] = match; const db = this.#load(); const record = db.tokens.find((t) => t.id === id); if (!record) { // Burn comparable time so unknown ids are indistinguishable from bad secrets. await argon2.verify( '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$mM48eTLB1F0hV8H8xToKffdOTM7ZbnDAJGdO+3l2gGA', secret, ).catch(() => false); return null; } const ok = await argon2.verify(record.hash, secret).catch(() => false); if (!ok) return null; record.lastUsed = new Date().toISOString(); this.#save(db); const { hash: _hash, ...safe } = record; return safe; } /** @returns {object[]} records without hashes */ list() { return this.#load().tokens.map(({ hash: _hash, ...safe }) => safe); } /** * @param {string} id * @returns {boolean} true when a token was revoked */ revoke(id) { const db = this.#load(); const before = db.tokens.length; db.tokens = db.tokens.filter((t) => t.id !== id); if (db.tokens.length === before) return false; this.#save(db); return true; } } /** * Extract a PAT from an HTTP request that may use Basic (git client) or * Bearer (API) authentication. * @param {string|undefined} authorization the Authorization header * @returns {string|null} the raw token candidate */ export function extractToken(authorization) { if (!authorization) return null; const [scheme, value] = authorization.split(' ', 2); if (!scheme || !value) return null; if (scheme.toLowerCase() === 'bearer') return value.trim(); if (scheme.toLowerCase() === 'basic') { let decoded; try { decoded = Buffer.from(value, 'base64').toString('utf8'); } catch { return null; } const colon = decoded.indexOf(':'); if (colon === -1) return null; const username = decoded.slice(0, colon); const password = decoded.slice(colon + 1); if (username !== OWNER.username && username !== '') return null; return password; } return null; } /** * Fastify helper — resolve the authenticated owner or reply 401. * @param {TokenStore} tokens * @returns {(request: any, reply: any) => Promise} */ export function makeRequireAuth(tokens) { return async function requireAuth(request, reply) { const candidate = extractToken(request.headers.authorization); const record = candidate ? await tokens.verify(candidate) : null; if (!record) { reply .code(401) .header('WWW-Authenticate', 'Basic realm="SPB Git", charset="UTF-8"') .send({ error: { code: 'unauthorized', message: 'A valid personal access token is required.' } }); return null; } return record; }; }