Documentation
¶
Overview ¶
Package token mints and verifies the HS256 JWTs that the team SPA, the /v1/team/account API and the /v1/team/transactor data plane all share.
The wire format is byte-compatible with the platform `jwt-simple` HS256 tokens (foundations/core/packages/token/src/token.ts): header {"typ":"JWT","alg":"HS256"}, a COMPACT JSON payload whose keys appear in the fixed order {extra, account, workspace, sub, exp, nbf} with undefined/empty keys omitted, base64url WITHOUT padding, and a raw HMAC-SHA256 signature over `header.payload`.
account and workspace, when present, MUST be UUIDs — the upstream `generateToken` throws otherwise and the transactor client refuses a workspace token that is missing either, so we enforce the same invariant at mint time.
The secret is the shared `SERVER_SECRET` (synced from KMS in production). One secret, one wire format — every team surface signs and verifies through this package. Ported VERBATIM from github.com/hanzoai/team-go/pkg/token.
Index ¶
Constants ¶
const ( RoleOwner = "owner" RoleAdmin = "admin" RoleMember = "member" RoleGuest = "guest" )
ErrMalformed is returned when a token is not three base64url segments. The workspace roles a token can carry, signed into extra.role at mint (clients/team selectWorkspace). This is the CLOSED set validInviteRole accepts.
Variables ¶
var ErrExpired = errors.New("token: expired")
ErrExpired is returned by Decode(verify=true) when the token's `exp` has passed.
var ErrMalformed = errors.New("token: malformed")
var ErrNoSecret = errors.New("token: empty secret")
ErrNoSecret is returned when signing or verifying is attempted with an empty secret. There is NO fallback literal: an unset SERVER_SECRET must surface as a hard error, never as a token silently signed with a public value.
var ErrNotYetValid = errors.New("token: not yet valid")
ErrNotYetValid is returned by Decode(verify=true) when the token's `nbf` is in the future.
var ErrSignature = errors.New("token: signature mismatch")
ErrSignature is returned when HMAC verification fails.
Functions ¶
func Generate ¶
func Generate(account, workspace string, extra map[string]any, exp int64, secret string) (string, error)
Generate mints an HS256 token for account (always) and workspace (optional — pass "" for an account/login token that is not yet scoped to a workspace). extra is folded in as the leading `extra` claim when non-empty (e.g. {"org":"acme"}). exp is the expiry as a unix-second timestamp — pass 0 to mint a token WITHOUT an `exp` claim (the wire-golden / non-expiring case). Both ids are validated as UUIDs.
Types ¶
type Token ¶
type Token struct {
Extra map[string]any `json:"extra,omitempty"`
Account string `json:"account"`
Workspace string `json:"workspace,omitempty"`
Sub string `json:"sub,omitempty"`
Exp int64 `json:"exp,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
}
Token is the decoded payload. Field order is the jwt-simple emit order so marshalling reproduces the upstream byte layout; `omitempty` drops the keys jwt-simple leaves `undefined`. account carries no omitempty: it is always present (the upstream payload always includes `account`).
func Decode ¶
Decode verifies (when verify is true) and parses an HS256 token. When verify is true it enforces the temporal claims with clockSkew tolerance: a token whose `exp` has passed is rejected (ErrExpired), a token whose `nbf` is in the future is rejected (ErrNotYetValid), and a token with NO `exp` is honored only until legacyExp (the pre-rollout grace) then rejected. This closes the permanent-replay window on a captured token (the transactor token rides in the URL path — log-prone — so a bounded lifetime is the mitigation until the front moves it off the URL, #60).
func (*Token) Org ¶
Org is the signed tenant claim. Every capability decision that names an org must come from here, never from a request body or Host.
func (*Token) Privileged ¶
Privileged reports whether this token's role confers FULL capability on the workspace's data — writing rows nobody projected, minting a seat in a meeting.
FAIL-CLOSED, and the closed case is the important one: an ABSENT role is NOT privileged. A role is only absent on a token that has not proven a workspace role (a pre-selectWorkspace session token) or one minted before this claim existed, and neither has demonstrated the thing this predicate is asked about. Guests are excluded by being outside the allowlist rather than by being named, so a role added to the invite vocabulary tomorrow starts unprivileged instead of silently full.
This is the ONE predicate for reduced capability. It used to be two string comparisons written twice, against extra.guest/extra.readonly — claims NOTHING in this repo ever mints, so both copies were inert AND they disagreed about whitespace.