Files
gomail/internal/webtoken/webtoken.go
T
2026-08-09 18:03:09 +01:00

138 lines
5.0 KiB
Go

// 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)
}