package centrifugo import ( "bytes" "encoding/json" "strconv" "time" "git.golem15.com/golem15/summercms/modules/lighthouse" "github.com/golang-jwt/jwt/v5" ) // anonymousTTL is the lifetime of Anonymous tokens. const anonymousTTL = 300 * time.Second // TokenIssuer signs Centrifugo connection and subscription tokens with // HS256. Its claims match the WinterCMS JwtTokenGenerator. It is safe for // concurrent use. type TokenIssuer struct { secret []byte ttl time.Duration // Now is the clock used for exp; nil means time.Now. Now func() time.Time } // NewTokenIssuer returns an issuer for secret with tokens valid for ttl // (DefaultTokenTTL when ttl is not positive). func NewTokenIssuer(secret string, ttl time.Duration) *TokenIssuer { if ttl <= 0 { ttl = DefaultTokenTTL } return &TokenIssuer{secret: []byte(secret), ttl: ttl} } // Configured reports whether a signing secret is set. func (i *TokenIssuer) Configured() bool { return i != nil && len(i.secret) > 0 } type userInfo struct { Name *string `json:"name"` } type userClaims struct { Sub string `json:"sub"` Exp int64 `json:"exp"` Info userInfo `json:"info"` } type channelClaims struct { Sub string `json:"sub"` Channel string `json:"channel"` Exp int64 `json:"exp"` } type anonymousClaims struct { Sub string `json:"sub"` Exp int64 `json:"exp"` } type identifierClaims struct { Sub string `json:"sub"` Exp int64 `json:"exp"` Info json.RawMessage `json:"info"` } // ForUser returns a connection token with exactly the claims sub (the user // id as a string), exp (now + TTL) and info {"name": u.Name}. info carries // nothing else: no email, no other ids. func (i *TokenIssuer) ForUser(u lighthouse.User) (string, error) { return i.sign(userClaims{Sub: userSub(u.ID), Exp: i.exp(i.ttl), Info: userInfo{Name: u.Name}}) } // Subscription returns a subscription token with the claims sub, channel // and exp. func (i *TokenIssuer) Subscription(u lighthouse.User, channel string) (string, error) { return i.sign(channelClaims{Sub: userSub(u.ID), Channel: channel, Exp: i.exp(i.ttl)}) } // Anonymous returns a connection token with sub "" and exp now + 5 minutes. func (i *TokenIssuer) Anonymous() (string, error) { return i.sign(anonymousClaims{Sub: "", Exp: i.exp(anonymousTTL)}) } // ForIdentifier returns a connection token for a non-user identifier with // the claims sub, exp and info. An empty info is encoded as [], as the // WinterCMS generator's empty PHP array is. func (i *TokenIssuer) ForIdentifier(identifier string, info map[string]any) (string, error) { raw := json.RawMessage("[]") if len(info) > 0 { b, err := marshal(info) if err != nil { return "", err } raw = b } return i.sign(identifierClaims{Sub: identifier, Exp: i.exp(i.ttl), Info: raw}) } // SubscriptionForIdentifier returns a subscription token for a non-user // identifier with the claims sub, channel and exp. func (i *TokenIssuer) SubscriptionForIdentifier(identifier, channel string) (string, error) { return i.sign(channelClaims{Sub: identifier, Channel: channel, Exp: i.exp(i.ttl)}) } func (i *TokenIssuer) exp(ttl time.Duration) int64 { now := time.Now if i != nil && i.Now != nil { now = i.Now } return now().Add(ttl).Unix() } func (i *TokenIssuer) sign(claims any) (string, error) { if !i.Configured() { return "", ErrNotConfigured } raw, err := marshal(claims) if err != nil { return "", err } return jwt.NewWithClaims(jwt.SigningMethodHS256, orderedClaims(raw)).SignedString(i.secret) } func userSub(id uint) string { return strconv.FormatUint(uint64(id), 10) } func marshal(v any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return nil, err } return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil } // orderedClaims keeps the claim order of the struct it was marshalled // from. The jwt.Claims methods are never used for signing. type orderedClaims json.RawMessage func (c orderedClaims) MarshalJSON() ([]byte, error) { return c, nil } func (orderedClaims) GetExpirationTime() (*jwt.NumericDate, error) { return nil, nil } func (orderedClaims) GetIssuedAt() (*jwt.NumericDate, error) { return nil, nil } func (orderedClaims) GetNotBefore() (*jwt.NumericDate, error) { return nil, nil } func (orderedClaims) GetIssuer() (string, error) { return "", nil } func (orderedClaims) GetSubject() (string, error) { return "", nil } func (orderedClaims) GetAudience() (jwt.ClaimStrings, error) { return nil, nil }