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 }