spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
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