SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
5.4 KB · 166 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/auth/token.mjs8 *  Purpose : Owner Personal Access Tokens — argon2-hashed, revocable9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { readFileSync, existsSync } from 'node:fs';14import { join } from 'node:path';15import { randomBytes } from 'node:crypto';16import argon2 from 'argon2';17import { atomicWriteJSON } from '../lib/util.mjs';18import { OWNER } from '../config.mjs';1920const TOKENS_FILE = 'tokens.json';21const TOKEN_PREFIX = 'spbgit';22const TOKEN_RE = /^spbgit_([0-9a-f]{8})_([0-9a-f]{40})$/;2324/**25 * PAT store backed by `data/tokens.json`.26 * Token string format: `spbgit_<id:8hex>_<secret:40hex>` — only the argon227 * hash of the secret is ever written to disk.28 */29export class TokenStore {30  /** @param {string} dataDir */31  constructor(dataDir) {32    this.path = join(dataDir, TOKENS_FILE);33  }3435  /** @returns {{tokens: object[]}} */36  #load() {37    if (!existsSync(this.path)) return { tokens: [] };38    try {39      const parsed = JSON.parse(readFileSync(this.path, 'utf8'));40      if (parsed && Array.isArray(parsed.tokens)) return parsed;41    } catch {42      /* corrupted token file treated as empty (locked out until new bootstrap) */43    }44    return { tokens: [] };45  }4647  #save(db) {48    atomicWriteJSON(this.path, db);49  }5051  /**52   * Mint a new PAT. The full token string is returned exactly once.53   * @param {string} [label]54   * @returns {Promise<{token: string, record: object}>}55   */56  async create(label = 'unnamed') {57    const id = randomBytes(4).toString('hex');58    const secret = randomBytes(20).toString('hex');59    const hash = await argon2.hash(secret, { type: argon2.argon2id });60    const record = {61      id,62      label: String(label).slice(0, 80),63      hash,64      created: new Date().toISOString(),65      lastUsed: null,66    };67    const db = this.#load();68    db.tokens.push(record);69    this.#save(db);70    return { token: `${TOKEN_PREFIX}_${id}_${secret}`, record };71  }7273  /**74   * Verify a raw PAT string. Updates `lastUsed` on success.75   * @param {string} token76   * @returns {Promise<object|null>} the token record (sans hash) or null77   */78  async verify(token) {79    const match = TOKEN_RE.exec(String(token ?? '').trim());80    if (!match) return null;81    const [, id, secret] = match;82    const db = this.#load();83    const record = db.tokens.find((t) => t.id === id);84    if (!record) {85      // Burn comparable time so unknown ids are indistinguishable from bad secrets.86      await argon2.verify(87        '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$mM48eTLB1F0hV8H8xToKffdOTM7ZbnDAJGdO+3l2gGA',88        secret,89      ).catch(() => false);90      return null;91    }92    const ok = await argon2.verify(record.hash, secret).catch(() => false);93    if (!ok) return null;94    record.lastUsed = new Date().toISOString();95    this.#save(db);96    const { hash: _hash, ...safe } = record;97    return safe;98  }99100  /** @returns {object[]} records without hashes */101  list() {102    return this.#load().tokens.map(({ hash: _hash, ...safe }) => safe);103  }104105  /**106   * @param {string} id107   * @returns {boolean} true when a token was revoked108   */109  revoke(id) {110    const db = this.#load();111    const before = db.tokens.length;112    db.tokens = db.tokens.filter((t) => t.id !== id);113    if (db.tokens.length === before) return false;114    this.#save(db);115    return true;116  }117}118119/**120 * Extract a PAT from an HTTP request that may use Basic (git client) or121 * Bearer (API) authentication.122 * @param {string|undefined} authorization the Authorization header123 * @returns {string|null} the raw token candidate124 */125export function extractToken(authorization) {126  if (!authorization) return null;127  const [scheme, value] = authorization.split(' ', 2);128  if (!scheme || !value) return null;129  if (scheme.toLowerCase() === 'bearer') return value.trim();130  if (scheme.toLowerCase() === 'basic') {131    let decoded;132    try {133      decoded = Buffer.from(value, 'base64').toString('utf8');134    } catch {135      return null;136    }137    const colon = decoded.indexOf(':');138    if (colon === -1) return null;139    const username = decoded.slice(0, colon);140    const password = decoded.slice(colon + 1);141    if (username !== OWNER.username && username !== '') return null;142    return password;143  }144  return null;145}146147/**148 * Fastify helper — resolve the authenticated owner or reply 401.149 * @param {TokenStore} tokens150 * @returns {(request: any, reply: any) => Promise<object|null>}151 */152export function makeRequireAuth(tokens) {153  return async function requireAuth(request, reply) {154    const candidate = extractToken(request.headers.authorization);155    const record = candidate ? await tokens.verify(candidate) : null;156    if (!record) {157      reply158        .code(401)159        .header('WWW-Authenticate', 'Basic realm="SPB Git", charset="UTF-8"')160        .send({ error: { code: 'unauthorized', message: 'A valid personal access token is required.' } });161      return null;162    }163    return record;164  };165}166