Skip to main content

ADR 0105 — Personal Access Token (brb_…) for the runner, scoped by construction to a single route

  • Status: Accepted
  • Date: 2026-08-22
  • Context: backlog of ADR 0104 ("long-lived account token (PAT) replacing the login replay... BEFORE distribution"), RN-424/425/426
  • Companion of: ADR 0103

Context​

apps/runner/src/auth.ts manually replicated what a browser does on its own: interactive e-mail/password on the first run, brabo_refresh/ brabo_csrf cookies extracted and persisted to ~/.brabo/runner-credentials.json, renewal via /auth/refresh on every reconnection attempt. ADR 0103 itself already recorded this as "the way possible with what exists today," flagged in the code as the module to replace. npm publish @brabo/runner had never run — and publishing the CLI in that state would distribute a password+cookie flow saved to disk, the block this delivery exists to close.

Two existing mechanisms were considered and discarded:

  • account_tokens — built for SINGLE-USE e-mail links (verification, password reset, initial password after the Keycloak migration). It has no concept of multiple live tokens per user, nor of scoping to a resource — reusing it would require widening the table and the use case into a shape that isn't its own.
  • Refresh token family rotation — refresh is CONSUMED and reissued on every use, with family revocation in case of detected reuse (ADR 0031). PAT is the opposite: presented repeatedly WITHOUT CHANGING, by design (that's what lets the user paste the value once into --token and never touch it again). Inheriting the rotation model would invent a guarantee the PAT doesn't need and would complicate, for no gain, what was supposed to be simple.

Decision​

New table, personal_access_tokens (migration 0049): id, user_id, project_id (NOT NULL — the only scope shape this delivery needs), name, token_hash (unique index), optional expires_at, revoked_at/revoked_reason, last_used_at, created_at. No scope_kind column. An enum with a single value populated today buys nothing — if a non-project scope shape shows up in the future, project_id NOT NULL wouldn't serve it anyway and the table would need its own migration then too. Deferring the enum to the day a second shape is real scheduled work, not before.

Hash: HMAC-SHA256 + pepper via hashDeToken()/TokenFactory, not argon2. The product's real pattern for a HIGH-entropy secret (256 bits of CSPRNG) is already that one — the same mechanism as refresh tokens and account tokens. Argon2 is reserved for a LOW-entropy secret (password), where resistance to dictionary attack matters; using it here would only break the indexed lookup WHERE token_hash = $1 for no gain, because there's no dictionary to resist against a 256-bit value generated by CSPRNG. The raw token is brb_<32 bytes in base64url>; the hash is computed over the string WITH the prefix (hashDe('brb_' + raw)), not over the value TokenFactory.gerar() returns — that one's hash is computed without the prefix and would be the wrong hash.

The central security decision: a PAT never authenticates on any route besides POST /projects/:projectId/runner-ticket, by CONSTRUCTION — not by an if that some new route tomorrow could forget to check.

The simplest alternative — the global JwtAuthGuard recognizing the brb_ prefix and populating request.user for ANY route — was considered and rejected: once request.user is set, RolesGuard/@RequireRole would authorize that PAT for everything the user's real role allows across the rest of the api. The "only requests a runner ticket" scope would become decorative — a leaked PAT would come to count as a full session.

The way out mirrors a pattern that already exists in the code for the same structural problem (traffic that shouldn't go through standard user authentication): IS_SERVICE_ROUTE_KEY/@ServiceRoute()/ EngineServiceGuard. A third branch, IS_PAT_ROUTE_KEY/@RequirePatAuth(), was added alongside the two existing ones in JwtAuthGuard: when set, the global guard returns true without attempting a JWT verify(), delegating entirely to the route's PatAuthGuard (@UseGuards(PatAuthGuard)), which only runs on the runnerTicket handler — never on terminalTicket, which remains exclusively session JWT (nowhere in the web calls runner-ticket; only the CLI calls it).

PatAuthGuard extracts the bearer; if it doesn't start with brb_, 401 — it never attempts dual-auth with JWT on that route. It calls PersonalAccessTokenRepository.validarEUsar(hash): ONE query UPDATE ... WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) RETURNING id, user_id, project_id, the same pattern as AccountTokenRepository.consumir(). Zero rows covers "doesn't exist," "revoked," and "expired" with the SAME response (401) — not giving whoever presents a stolen/expired token the information of which of the three is the reason. Wrong project scope (returned project_id ≠ the route's :projectId) is 403, a different category: the token authenticated, it just doesn't have rights to THIS project.

last_used_at is an UNCONDITIONAL SET ... = now(), in the same validation query — no throttling. The alternative considered (only touching it when "NULL or >5min old," with that condition in the SAME WHERE) had a real bug: a PAT presented twice within less than 5 minutes would fall outside the WHERE the second time, and the query would return zero rows for a VALID token — rejecting a legitimate reconnection with 401 (the runner's retry loop reconnects within seconds, not minutes). The cost of not throttling is a one-row UPDATE by unique index; the real worst case (up to 10 attempts in a row, the runner's ceiling) doesn't come close to being a load problem. Simpler and without that class of bug.

RolesGuard/ResolveEffectiveRoleUseCase keep running after PatAuthGuard, unchanged — belt and suspenders: if the PAT's owner loses access to the project through the normal route (ProjectMember/workspace), the token stops authorizing even without being explicitly revoked.

Issuing/listing/revoking is scoped to the user's OWN account, in the query's WHERE — never an in-memory filter after fetching everything. Revocation by a maintainer of ANOTHER user's PAT (incident response — a departed dev with a leaking token) is out of scope for this delivery, declared.

Consequences​

  • Unblocks the next line of ADR 0104's backlog: npm publish @brabo/runner stops distributing a password+cookie flow saved to disk. apps/runner/src/auth.ts loses interactive login, cookies, and the credentials file entirely — the PAT arrives ready via --token/BRABO_ACCOUNT_TOKEN, never written to disk by the CLI.
  • route-surface.spec.ts keeps classifying runner-ticket as role:developer — correct, it's the same role requirement as always, only the mechanism for establishing identity changed. docs/security-surface.md gained a prose note saying this route accepts only PAT, because the automatic table doesn't distinguish the two mechanisms.
  • No change to the engine (Elixir) — all the work is api + runner + web + docs.
  • A user can have several live PATs at once on the same project (one per machine, on purpose) — unlike account_tokens, there's no supersede: issuing a new one doesn't invalidate previous ones.