4.5 KiB
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
constantTimeEqualfrom@suaveplan/crypto-utils. - Injectable
Clockfor deterministic expiry tests. - Configurable
defaultTTLper instance (DEFAULT_TOKEN_TTL_MS = 24 h) and per-callexpiresInoverride. - Hard cap on active tokens (
TOKEN_MANAGER_MAX_ENTRIES = 10_000_000). EventBusLikeintegration:token:created/token:revokedcarry context but never the integrity tag itself.- OTel:
share.token.create_total,share.token.validate_total,share.token.revoked_total, plusshare.token.create/validatespans.
Usage: Basic Example
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
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): ShareTokenvalidate(id: TokenId): TokenValidationResultrevoke(id: TokenId): voidlist(): 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
permissionsbefore 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 newTokenManager; the old one's tokens become invalid because the new master key produces different integrity tags. validatealways 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 settingrevoked = true. (We DO short-circuit onnot_found, since a missing token cannot have been tampered with.)- The Zod schema rejects
expiresIn: 0and negative values; these would produce already-expired tokens which are dead on arrival. revokeis idempotent so a duplicate revocation does not double-countshare.token.revoked_total.