@rubriclab/rubrot-tokens
v0.3.0
Published
One scoped-bearer-token engine for the fleet: <prefix>_<id>_<secret> mint/parse/hash, ranked or orthogonal capabilities, adapter-backed bearer authentication
Keywords
Readme
@rubriclab/rubrot-tokens
One scoped-bearer-token engine for the fleet — vault's lib/service-tokens.ts pattern (the acknowledged origin; metal's copy cites it), extracted so the next app stops forking it. Zero dependencies; storage is an injected adapter (the rubrot-auth idiom), so each app binds its own table names.
The invariants (all kept)
- Format
<prefix>_<8-byte-hex-id>_<32-byte-base64url-secret>. The id is hex, so the first_after the prefix delimits — the secret may itself contain_. - Plaintext once:
mintTokenis the only place the full token exists; onlysecretHash(SHA-256 of the secret part) is stored. Rotation = revoke + re-mint. - Check order (load-bearing): invalid token → revoked → expired → creator inactive → capability. Constant-time on the secret; a wrong secret is indistinguishable from an unknown id.
- Creator ceiling: the adapter answers
creatorActive— revoking a person revokes their tokens. - last_used_at stamps best-effort on success, never on failure, never fails the call.
- Scope rows narrow a token to resources:
Scopeis app-defined, loaded by the adapter, carried onto the identity untouched. What "in scope" means stays an app rule.
Capability strategies
The one real fork between the sources, kept as a choice:
rankedCapabilities(["read", "operate", "exec"]) // metal: higher covers lower
orthogonalCapabilities(["read", "write"]) // vault: disjoint — write NEVER readsBoth fail closed on capabilities outside the declared set.
Wiring
// lib/tokens.ts — bind once per app
import { authenticateBearer, mintToken, rankedCapabilities } from "@rubriclab/rubrot-tokens";
const strategy = rankedCapabilities(["read", "operate", "exec"] as const);
const adapter = {
findById: (id) => /* select from service_tokens join users → TokenRecord | null */,
loadScope: (tokenId) => /* select from service_token_scopes → your Scope */,
touchLastUsed: (tokenId) => /* update ... set last_used_at = now() */,
};
export const authenticate = (authorization: string | null, required: Capability) =>
authenticateBearer(adapter, strategy, { prefix: "metal", authorization, required });The BearerResult failure shape plugs straight into @rubriclab/rubrot-api's withAuth: { ok: false, status, error, code? }. code is an optional hint that lines up with rubrot-api's AuthFailure.code — the engine sets AUTH_MISSING when no bearer was presented; that wrapper carries a set code through and otherwise defaults it by status. evaluateToken (the pure gate) and parseToken/hashToken/constantTimeEqualHex/parseBearerAuthorization are exported for adapters and tests.
Typing the identity
BearerResult<Cap, Scope> and TokenIdentity<Cap, Scope> ARE the types to alias — don't re-declare them locally:
type Capability = "read" | "operate" | "exec";
type FleetScope = { machineIds: readonly string[]; appNames: readonly string[] };
export type Identity = TokenIdentity<Capability, FleetScope>;
export type Result = BearerResult<Capability, FleetScope>;Admin (mint / list / revoke)
The CRUD half both forks shipped, extracted. Bind a TokenAdminAdapter (the WRITE port, separate from the auth-path adapter so an auth-only app needn't implement it) and the package owns the discipline: plaintext exists once (only secretHash reaches the adapter), the scope check runs before persist (a rejected mint leaves nothing behind), and status derives one way everywhere.
import {
createServiceToken,
revokeServiceToken,
statusOf, // (expiresAt, revokedAt, now?) → "active" | "expired" | "revoked"
tokenView, // ServiceTokenRow → ServiceTokenView (ISO timestamps + status)
type TokenAdminAdapter,
} from "@rubriclab/rubrot-tokens";
const admin: TokenAdminAdapter<Capability, FleetScope> = {
// Optional: throw to reject a mint out of reach (unknown machine, ScopeBeyondReach).
checkScope: async (scope, createdBy) => { /* app rule — throws abort the mint */ },
persist: async (token) => { /* insert service_tokens (token.secretHash) + scope rows, atomically */ },
revoke: async (id) => { /* update ... set revoked_at = coalesce(revoked_at, now()); return rowCount > 0 */ },
};
// Mint — the plaintext is in the result exactly once; hand it out, never store it.
const { id, token } = await createServiceToken(admin, {
prefix: "metal", name, capability, scope, expiresInDays, createdBy,
});
await revokeServiceToken(admin, id); // WHO may revoke stays an app rule, enforced first.listServiceTokens stays app-side (its SQL and scope columns are yours) — build the list with your query, then map each row through tokenView.
