package auth import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) // CheckInQR is the JWT payload stamped into each guest's QR code on // their RSVP confirmation. Tier 2 Block H. // // Why a separate JWT (vs reusing the access JWT or the invitation // token): // - We want a long-lived "good for the whole event window" code that // persists even after the guest's session expires. A 6-hour JWT // with explicit (event_id, guest_id) is the right shape. // - The QR ends up on paper / wallpapers / screenshots; a // guessable-shape token like the invitation slug would be too // easy to fudge. // - The signed payload lets the scanner verify offline (in theory) // and the API verify cheaply (HMAC-SHA256, no DB lookup for // authentication of the QR — DB only confirms guest membership // before recording). type CheckInQR struct { EventID uuid.UUID `json:"event_id"` GuestID uuid.UUID `json:"guest_id"` jwt.RegisteredClaims } // CheckInQRSigner mints and verifies the QR JWTs. Same HMAC secret as // the rest of the auth surface (GG_JWT_SECRET) so production secrets // management already covers this code path. type CheckInQRSigner struct { secret []byte issuer string parser *jwt.Parser ttl time.Duration } // NewCheckInQRSigner expects the same shape of secret as NewJWTSigner — // at least 32 bytes. ttl bounds how far ahead of the event the QR // stays valid; the API caller typically passes (eventDate.Sub(now) + 24h) // so the code is good through any reasonable lateness window. func NewCheckInQRSigner(secret string, issuer string, ttl time.Duration) (*CheckInQRSigner, error) { if len(secret) < 32 { return nil, fmt.Errorf("jwt secret must be at least 32 bytes") } if ttl <= 0 { return nil, fmt.Errorf("qr ttl must be positive") } return &CheckInQRSigner{ secret: []byte(secret), issuer: issuer, ttl: ttl, parser: jwt.NewParser( jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(issuer), jwt.WithExpirationRequired(), ), }, nil } // Issue mints a QR JWT good until the larger of (a) the event date + 24h // or (b) the signer's default TTL. The caller passes eventDate so the // QR lifetime matches the actual event window — a wedding three months // out shouldn't get a code that expires before the day. func (s *CheckInQRSigner) Issue(eventID, guestID uuid.UUID, eventDate time.Time, now time.Time) (string, time.Time, error) { exp := now.Add(s.ttl) if dayAfter := eventDate.Add(24 * time.Hour); dayAfter.After(exp) { exp = dayAfter } claims := CheckInQR{ EventID: eventID, GuestID: guestID, RegisteredClaims: jwt.RegisteredClaims{ Issuer: s.issuer, Subject: guestID.String(), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Second)), ExpiresAt: jwt.NewNumericDate(exp), ID: uuid.NewString(), }, } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := tok.SignedString(s.secret) if err != nil { return "", time.Time{}, err } return signed, exp, nil } // Parse verifies the JWT and returns the bound (event_id, guest_id). // Returns ErrExpiredJWT specifically when the only issue is expiry, so // the API can render a friendlier message; everything else maps to // ErrInvalidJWT. func (s *CheckInQRSigner) Parse(raw string) (*CheckInQR, error) { claims := &CheckInQR{} tok, err := s.parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { return s.secret, nil }) if err != nil { if errors.Is(err, jwt.ErrTokenExpired) { return nil, ErrExpiredJWT } return nil, ErrInvalidJWT } if !tok.Valid { return nil, ErrInvalidJWT } if claims.EventID == uuid.Nil || claims.GuestID == uuid.Nil { return nil, ErrInvalidJWT } return claims, nil }