Chronicle JWT Claims & Dev Tokens
chronicle intermediate 4 min read
ELI5
A JWT is a tamper-evident wax-sealed envelope: the relay only opens it if the wax (HS256 signature) matches the secret, the issuer’s stamp is right, and the expiry hasn’t passed. In dev you can also slip in a hand-written note shaped dev-alice — but only if the door explicitly allows hand-written notes.
Technical Deep Dive
Algorithm and Configuration
Tokens are HS256 JWTs verified with jsonwebtoken::decode. Configuration is environment-driven (ADR-002):
| Env var | Purpose |
|---|---|
RELAY_JWT_SECRET | HS256 shared secret (required for production verification) |
RELAY_JWT_ISSUER | Optional; if set, iss claim must match |
RELAY_JWT_AUDIENCE | Optional; if set, aud claim must match |
RELAY_ALLOW_DEV_TOKENS | When true, accepts dev-{user_id} strings as valid identities |
ChronicleTokenClaims
classDiagram class ChronicleTokenClaims { +String sub +Option~i64~ iat +Option~i64~ exp +Option~i64~ nbf +Option~String~ iss +Option~String~ aud +Option~String~ jti +Option~String~ name +Option~String~ email +Option~String~ color +Option~String~ permission +Option~String~ session_id +display_name() str } class VerifiedIdentity { +String user_id +String user_name +Option~String~ color +Option~String~ session_id +bool is_dev_token } ChronicleTokenClaims --> VerifiedIdentity : "verify()"display_name() returns name if present, otherwise falls back to sub (relay/src/services/auth.rs:108).
Verification Flow
flowchart TD A[token string] --> B{starts with 'dev-'?} B -- yes --> C{allow_dev_tokens?} C -- no --> R1[DevTokensDisabled → TOKEN_INVALID] C -- yes --> D[VerifiedIdentity is_dev_token=true] B -- no --> E{jwt_secret set?} E -- no --> R2[SecretNotConfigured] E -- yes --> F[decode HS256 with Validation: exp,nbf,iss,aud] F -- err: ExpiredSignature --> R3[TokenExpired] F -- err: InvalidSignature --> R4[SignatureInvalid] F -- err: InvalidIssuer --> R5[IssuerMismatch] F -- err: InvalidAudience --> R6[AudienceMismatch] F -- ok --> G[ChronicleTokenClaims → VerifiedIdentity]Dual Strategy
| Token | Format | When |
|---|---|---|
| JWT | header.payload.signature (HS256) | Production |
| Dev | dev-{user_id} literal | Dev only, gated by RELAY_ALLOW_DEV_TOKENS=true |
Dev tokens carry no name, email, or color; the user_name therefore equals the user_id until the client provides a separate display name.
Key Terms
- AuthError → enum in
services/auth.rscovering every reason a verification can fail; mapped toErrorCode::Token*for wire transport. - permission claim → optional string
"viewer" | "editor" | "admin"; the relay parses it but the codebase currently uses a separatePermissionLevelenum that is#[allow(dead_code)]— seeservices/connection.rs. - is_dev_token → audit-only flag preserved on
VerifiedIdentityso logs can flag dev sessions.
Q&A
Q: What happens if RELAY_JWT_SECRET is unset and a JWT arrives?
A: AuthError::SecretNotConfigured → TOKEN_VERIFICATION_FAILED on the wire.
Q: Is iss always validated?
A: Only if RELAY_JWT_ISSUER is set; same for RELAY_JWT_AUDIENCE. They are opt-in tightenings.
Q: Can a dev token impersonate any user?
A: Yes — that is the design. dev-alice becomes user_id alice with no signature check, which is why RELAY_ALLOW_DEV_TOKENS MUST be false in production.
Examples
A successful production exchange: client sends {"type":"authenticate","token":"eyJ...","protocol_version":1}; relay decodes claims sub="u_42", name="Ada", color="#D4A574"; relay replies auth_result { success: true, user_id:"u_42", user_name:"Ada" } and stores a VerifiedIdentity on the session.
neighbors on the map
- Auth-First WebSocket Handshake debugging AUTH_REQUIRED errors on the relay
- Documents REST & SQLite Persistence wiring a new document property
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry
- LORE RBAC & Airlock Auth Flow implementing authentication in a new LORE page