// Package webtoken implements minimal JWT issuing/verification (HS256 only) // for webmail/admin session tokens — hand-rolled on stdlib crypto/hmac // rather than a third-party JWT library, matching the project's // dependency-free principle. Supports exactly what session tokens need: // a subject (user ID), an expiry, and tamper-evident signing. No JWK sets, // no algorithm negotiation, no other algorithms — HS256 with a server-side // secret is the right tool for "did we issue this token", nothing more. package webtoken import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "strings" "time" ) type Claims struct { Subject string `json:"sub"` TenantID string `json:"tenant_id,omitempty"` Role string `json:"role,omitempty"` Purpose string `json:"purpose,omitempty"` // e.g. "mfa_pending", "password_reset" — empty means a normal full session Ctx string `json:"ctx,omitempty"` // purpose-specific binding, e.g. a Fingerprint of the password hash for password_reset IssuedAt int64 `json:"iat"` ExpiresAt int64 `json:"exp"` } var header = base64URLEncode([]byte(`{"alg":"HS256","typ":"JWT"}`)) // Issue creates a signed token for the given subject, valid for ttl. func Issue(secret, subject, tenantID, role string, ttl time.Duration) (string, error) { return IssueWithPurpose(secret, subject, tenantID, role, "", ttl) } // IssueWithPurpose is Issue plus a purpose tag — used for tokens that are // NOT a full session (MFA-pending, password-reset) so a caller checking // claims.Purpose can refuse to treat them as one, even though they're // structurally the same JWT and share the same verification path. func IssueWithPurpose(secret, subject, tenantID, role, purpose string, ttl time.Duration) (string, error) { now := time.Now().UTC() claims := Claims{ Subject: subject, TenantID: tenantID, Role: role, Purpose: purpose, IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(), } payloadJSON, err := json.Marshal(claims) if err != nil { return "", fmt.Errorf("marshal claims: %w", err) } payload := base64URLEncode(payloadJSON) signingInput := header + "." + payload sig := sign(secret, signingInput) return signingInput + "." + sig, nil } // IssueResetToken issues a password_reset purpose token bound to // passwordHashFingerprint (see Fingerprint) — the fingerprint of the // user's password hash at issuance time. Because resetting the password // changes that hash, FingerprintMatches will reject the same token on any // second use, giving single-use semantics with no server-side token store. func IssueResetToken(secret, subject, tenantID, role, passwordHashFingerprint string, ttl time.Duration) (string, error) { now := time.Now().UTC() claims := Claims{ Subject: subject, TenantID: tenantID, Role: role, Purpose: "password_reset", Ctx: passwordHashFingerprint, IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(), } payloadJSON, err := json.Marshal(claims) if err != nil { return "", fmt.Errorf("marshal claims: %w", err) } payload := base64URLEncode(payloadJSON) signingInput := header + "." + payload sig := sign(secret, signingInput) return signingInput + "." + sig, nil } // Fingerprint returns a short, non-reversible fingerprint of s (e.g. a // password hash), suitable for embedding in a token to detect whether the // underlying value has changed since the token was issued. func Fingerprint(s string) string { sum := sha256.Sum256([]byte(s)) return base64.RawURLEncoding.EncodeToString(sum[:8]) } // FingerprintMatches reports, in constant time, whether s's Fingerprint // matches the one embedded in claims.Ctx. func FingerprintMatches(claims *Claims, s string) bool { return subtle.ConstantTimeCompare([]byte(Fingerprint(s)), []byte(claims.Ctx)) == 1 } // Verify checks signature and expiry, returning the claims if valid. func Verify(secret, token string) (*Claims, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("malformed token") } signingInput := parts[0] + "." + parts[1] expectedSig := sign(secret, signingInput) // Constant-time comparison — avoids leaking signature validity via timing. if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(parts[2])) != 1 { return nil, fmt.Errorf("invalid signature") } payloadJSON, err := base64URLDecode(parts[1]) if err != nil { return nil, fmt.Errorf("decode payload: %w", err) } var claims Claims if err := json.Unmarshal(payloadJSON, &claims); err != nil { return nil, fmt.Errorf("unmarshal claims: %w", err) } if time.Now().UTC().Unix() > claims.ExpiresAt { return nil, fmt.Errorf("token expired") } return &claims, nil } func sign(secret, signingInput string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signingInput)) return base64URLEncode(mac.Sum(nil)) } func base64URLEncode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } func base64URLDecode(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }