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
--tokenand 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/runnerstops distributing a password+cookie flow saved to disk.apps/runner/src/auth.tsloses 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.tskeeps classifyingrunner-ticketasrole:developer— correct, it's the same role requirement as always, only the mechanism for establishing identity changed.docs/security-surface.mdgained 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.