package customerauth import ( "crypto/hmac" "encoding/base64" "strconv" "strings" "time" ) // Purpose-scoped tokens (email verification, password reset) reuse the session // Signer's HMAC secret but bind a purpose + subject, so a token minted for one // flow cannot be replayed in another. Format: // // base64url( NUL NUL ) "." base64url(hmac) // // subject is the normalized email. Like session tokens these are signed, not // encrypted: they carry no secret, only a purpose, an email and an expiry, and // the HMAC makes them tamper-evident. const ( purposeVerifyEmail = "verify" purposePasswordReset = "reset" ) // IssuePurpose returns a signed, purpose-bound token for subject that expires // after ttl. func (s *Signer) IssuePurpose(purpose, subject string, ttl time.Duration) string { exp := time.Now().Add(ttl).Unix() payload := purpose + "\x00" + subject + "\x00" + strconv.FormatInt(exp, 10) b := base64.RawURLEncoding.EncodeToString([]byte(payload)) return b + "." + s.sign(b) } // ParsePurpose verifies a purpose token and returns its subject. It requires the // embedded purpose to match want and the token to be unexpired, returning // ErrInvalidToken for a bad signature/format/purpose and ErrExpiredToken when // the token has expired. func (s *Signer) ParsePurpose(want, token string) (string, error) { b, sig, ok := strings.Cut(token, ".") if !ok || b == "" || sig == "" { return "", ErrInvalidToken } if !hmac.Equal([]byte(sig), []byte(s.sign(b))) { return "", ErrInvalidToken } raw, err := base64.RawURLEncoding.DecodeString(b) if err != nil { return "", ErrInvalidToken } parts := strings.Split(string(raw), "\x00") if len(parts) != 3 || parts[0] != want { return "", ErrInvalidToken } exp, err := strconv.ParseInt(parts[2], 10, 64) if err != nil { return "", ErrInvalidToken } if time.Now().Unix() >= exp { return "", ErrExpiredToken } return parts[1], nil }