CRUMB a card from devarno-cloud

JWT Auth & Tier Claims

tektree intermediate 5 min read

ELI5

A JWT is a tamper-evident wristband: the door staff stamped it (private key), checked the watermark on the way in (public key), and printed the guest’s tier on the band so every booth inside knows what they paid for without having to call the front desk again.

Technical Deep Dive

The canonical claim shape is in libs/shared-go/pkg/auth/auth.go and travels alongside the BetterAuth-styled login endpoints exposed at the gateway.

Claims Class Diagram

classDiagram
class Claims {
+string UserID
+string Tier
+string Role
+RegisteredClaims
}
class Session {
+string UserID
+string Tier
+string Role
+time.Time CreatedAt
+time.Time ExpiresAt
}
class TokenValidator {
+PublicKey rsa.PublicKey
+Validate(raw string) (*Claims, error)
}
Claims <-- TokenValidator : produces
Claims --> Session : hydrates

Auth Handshake

sequenceDiagram
autonumber
participant Client
participant GW as api-gateway
participant US as user-service
Client->>GW: POST /api/v1/auth/login {email, password}
GW->>US: proxy (no auth required for /auth/*)
US->>US: bcrypt verify (cost 12) + load tier/role
US-->>GW: {access_token (RS256, 15 min), refresh cookie (7d, HTTP-only Secure SameSite=Strict)}
GW-->>Client: same response
Client->>GW: GET /api/v1/users/me + Authorization: Bearer <jwt>
GW->>GW: jwt.Parse → Claims{UserID, Tier, Role}
GW->>US: forward + X-User-ID, X-User-Tier
US-->>Client: 200

Per docs/docs/architecture/SECURITY_ARCHITECTURE.md: access tokens are RS256 with a 15-minute lifetime; refresh tokens are opaque, 7 days (30 with remember_me), stored as HTTP-only / Secure / SameSite=Strict cookies and rotated on every use. Passwords use bcrypt cost 12 with a min-8 / upper / digit / special policy.

The gateway’s auth middleware (services/api-gateway/internal/middleware/middleware.go:52-97) calls jwt.Parse with the configured JWTSecret (HS — symmetric — for the gateway scaffold today; SECURITY_ARCHITECTURE.md prescribes RS256 with the public key in BaseConfig.JWTPublicKey, which is the target state). It extracts subuser_id and tier (defaulting to "free") and writes them into the Gin context for downstream proxying.

Tier Values

free | pro | team | enterprise — in libs/proto/common.proto the UserTier enum is FREE=1, PRO=2, TEAM=3 (no enterprise yet on the wire). The string forms are what flow in headers and DB.

Key Terms

  • Access token → RS256 JWT, 15 min, carries sub, tier, role, exp, iat.
  • Refresh token → opaque, 7 d (30 d remember-me), HTTP-only cookie, rotated on every refresh.
  • Tierfree | pro | team (and enterprise per SECURITY_ARCHITECTURE.md but not yet in the proto enum).
  • Trust boundary → JWT verified once at the gateway; downstreams trust X-User-ID / X-User-Tier.

Q&A

Q: A token issued 12 minutes ago still works but the next request 4 minutes later returns 401. What happened? A: The 15-minute access window expired between calls. The client should hit /api/v1/auth/refresh (which consumes and rotates the refresh cookie) to obtain a fresh access JWT.

Q: How does a downstream service know a user’s role? A: It does not, today. Only X-User-ID and X-User-Tier are forwarded by services/api-gateway/internal/handlers/handlers.go. To gate on role, either extend the proxy to forward X-User-Role from the parsed Role claim or have the downstream call user-service.

Q: Refresh token reuse should fail. What enforces that? A: Rotation: every successful /auth/refresh issues a new opaque token and invalidates the previous one. A second use of the old token must be treated as a theft signal.

Examples

A new claim org_id is needed by knowledge-service. Steps: (1) add OrgID string to Claims in libs/shared-go/pkg/auth/auth.go; (2) include it when minting tokens in user-service; (3) extend gateway middleware to set c.Set("org_id", claims.OrgID) and req.Header.Set("X-Org-ID", orgID) in the proxy handler.

neighbors on the map