Files
summercms/modules/bouncer/mint.go
Jakub Zych c14238b94b feat(bouncer): stamp auth_time at sign-in and carry it unchanged through refresh (C-CR-02)
- MintAudience stamps auth_time equal to iat; refreshAudience copies the
  presented token's value and never renews it (absent stays absent)
- bouncer.ClaimAuthTime and bouncer.VerifyAuthTime for plugins, with the
  same signature, audience and expiry checks as VerifyClaims
- TestAuthTimeSmoke traces mint, refresh and read
2026-10-23 16:13:49 +02:00

97 lines
3.0 KiB
Go

package bouncer
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// prvHash is sha1("Golem15\User\Models\User"), the lock-subject PHP jwt-auth
// stamps on every frontend user token. Backend tokens do not carry it.
const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"
const (
// AudienceUser is the frontend jwt-guard audience.
AudienceUser = "user"
// AudienceBackend is the admin jwt-guard audience.
AudienceBackend = "backend"
)
// ClaimAuthTime is the JWT claim holding the time of the sign-in that started
// the session (OIDC Core's auth_time, NumericDate seconds). Every direct mint
// is a sign-in and stamps it equal to iat; a refresh copies it unchanged, so
// a "fresh sign-in" check compares auth_time, never iat.
const ClaimAuthTime = "auth_time"
type registeredClaims struct {
jwt.RegisteredClaims
Prv string `json:"prv,omitempty"`
AuthTime *jwt.NumericDate `json:"auth_time,omitempty"`
}
// Mint signs a frontend-audience HS256 token. iss is the minting endpoint URL.
// The returned jti is the token's own jti claim.
func Mint(secret, sub, issuerURL string, ttl time.Duration) (string, string, error) {
return MintAudience(secret, sub, issuerURL, ttl, AudienceUser)
}
// MintAudience signs an HS256 token for audience. Frontend tokens keep the PHP
// prv lock-subject; backend tokens omit it. Every MintAudience call is a
// sign-in, so the token's auth_time (ClaimAuthTime) equals its iat.
func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string) (string, string, error) {
now := time.Now()
return mintAudience(secret, sub, issuerURL, ttl, audience, now, now)
}
// mintAudience signs the token issued at now. authTime, when non-zero,
// becomes the auth_time claim; a zero authTime mints a token without one. A
// sign-in passes the same instant as now and authTime, so auth_time equals iat.
func mintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string, now, authTime time.Time) (string, string, error) {
if strings.TrimSpace(secret) == "" {
return "", "", fmt.Errorf("bouncer: jwt secret is empty")
}
if strings.TrimSpace(audience) == "" {
return "", "", fmt.Errorf("bouncer: jwt audience is empty")
}
jti, err := newJTI()
if err != nil {
return "", "", err
}
prv := ""
if audience == AudienceUser {
prv = prvHash
}
claims := registeredClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuerURL,
Subject: sub,
Audience: jwt.ClaimStrings{audience},
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: jti,
},
Prv: prv,
}
if !authTime.IsZero() {
claims.AuthTime = jwt.NewNumericDate(authTime)
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {
return "", "", err
}
return signed, jti, nil
}
func newJTI() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}