# `sharing/tokens/tokens.ts` _(no submodules)_ ## Purpose `TokenManager` issues, validates, revokes, and lists revocable share tokens. Every token carries an HMAC-SHA-256 integrity tag computed under a per-instance master key over the canonical wire form (id || partitionId || createdBy || sorted permissions || createdAt || expiresAt). Validation re-derives the tag in constant time and rejects any tampering. Tokens have a configurable default TTL (24 h) overridable per call via `expiresIn`. ## Features - Constant-time integrity verification via `constantTimeEqual` from `@suaveplan/crypto-utils`. - Injectable `Clock` for deterministic expiry tests. - Configurable `defaultTTL` per instance (`DEFAULT_TOKEN_TTL_MS = 24 h`) and per-call `expiresIn` override. - Hard cap on active tokens (`TOKEN_MANAGER_MAX_ENTRIES = 10_000_000`). - `EventBusLike` integration: `token:created` / `token:revoked` carry context but never the integrity tag itself. - OTel: `share.token.create_total`, `share.token.validate_total`, `share.token.revoked_total`, plus `share.token.create` / `validate` spans. ## Usage: Basic Example ```ts import { TokenManager } from "@suaveplan/drive-sharing/sharing/tokens"; import { partitionId, userId } from "@suaveplan/drive-sharing/types/common"; const tokens = new TokenManager(); const token = tokens.create({ partitionId: partitionId("p"), createdBy: userId("alice"), permissions: ["read"], }); const result = tokens.validate(token.id); console.log(result.valid); // true ``` ## Usage: Advanced Example ```ts import { TokenManager } from "@suaveplan/drive-sharing/sharing/tokens"; import { partitionId, userId } from "@suaveplan/drive-sharing/types/common"; let now = 0; const events: Array<[string, unknown]> = []; const masterKey = new Uint8Array(32).fill(0x42); const tokens = new TokenManager({ clock: () => now, defaultTTL: 60_000, masterKey, events: { on: () => () => {}, emit: (k, p) => events.push([k, p]) }, }); const token = tokens.create({ partitionId: partitionId("p"), createdBy: userId("alice"), permissions: ["read"], expiresIn: 5_000, }); now = 1_000; console.log(tokens.validate(token.id).valid); // true now = 6_001; console.log(tokens.validate(token.id)); // { valid: false, reason: "expired" } tokens.revoke(token.id); console.log(tokens.validate(token.id)); // { valid: false, reason: "revoked" } ``` ## API Reference ### `class TokenManager` - `constructor(config?: TokenManagerConfig)` - `create(options: CreateTokenOptions): ShareToken` - `validate(id: TokenId): TokenValidationResult` - `revoke(id: TokenId): void` - `list(): ShareToken[]` ### `interface CreateTokenOptions` `{ partitionId, createdBy, permissions, expiresIn? }`. `permissions` must be non-empty (Zod-enforced). ### `interface ShareToken` `{ id, partitionId, createdBy, permissions, createdAt, expiresAt, revoked, integrity }`. ### `interface TokenValidationResult` `{ valid, token?, reason? }` — `reason` is one of `not_found`, `revoked`, `expired`. ### `interface TokenManagerConfig` `{ clock?, defaultTTL?, masterKey?, events?, idGenerator? }`. Default master key is a fresh 32-byte CSPRNG buffer; default TTL is 24 hours. ### `interface TokenManagerEvents` - `token:created` — full token (including the integrity tag, since the consumer who created the token already knows it) - `token:revoked` — `{ id, partitionId }` ## Implementation Notes - The canonical wire form sorts `permissions` before serialising so two tokens with the same logical content but different array order produce identical integrity tags. This protects against a subtle bug where a downstream serializer reorders array elements. - The HMAC key (`masterKey`) is held in instance state — it never appears in events, logs, or token bodies. Rotation is done by constructing a new `TokenManager`; the old one's tokens become invalid because the new master key produces different integrity tags. - `validate` always recomputes the integrity tag, even when the token is revoked or expired. This is deliberate: an attacker who has tampered with the in-memory token map cannot evade detection by also setting `revoked = true`. (We DO short-circuit on `not_found`, since a missing token cannot have been tampered with.) - The Zod schema rejects `expiresIn: 0` and negative values; these would produce already-expired tokens which are dead on arrival. - `revoke` is idempotent so a duplicate revocation does not double-count `share.token.revoked_total`.