package auth import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) // ScannerClaims is the bearer token a host's phone uses to drive the // check-in scanner. Tier 2 Block H follow-up. // // The desktop event-detail page mints one of these via POST // /events/{id}/scanner-ticket, renders the magic URL into a QR, the // host scans it with their phone camera, and the scanner page reads // the token out of the URL. No second login required — the desktop // host's session is the source of authority for the ticket-issuing // call. // // Scoped: a scanner JWT only authorises POST /events/{id}/check-in, // POST /events/{id}/walk-ins, and GET /events/{id}/check-ins for the // `EventID` it carries. Everything else returns 401. This means a // host who texted the magic link to a door volunteer hasn't given out // blanket account access. // // The token carries Audience="scanner" so the normal Bearer-token // middleware can reject it for non-scanner endpoints even though it // shares the same HMAC secret as the platform session JWT. type ScannerClaims struct { UserID uuid.UUID `json:"user"` EventID uuid.UUID `json:"event"` jwt.RegisteredClaims } // ScannerJWTAudience is the audience value that disambiguates a scanner // token from a regular session access token (both are HS256 with the // platform secret). Exported so middleware can refuse cross-purpose use. const ScannerJWTAudience = "scanner" // ScannerJWTSigner mints + verifies the scoped scanner tokens. Same // HMAC secret as the rest of the platform. type ScannerJWTSigner struct { secret []byte issuer string ttl time.Duration parser *jwt.Parser } // NewScannerJWTSigner — ttl bounds how long a magic URL stays usable // before the host has to mint a fresh one. Default in the API is 4h // so a door volunteer can stay on the scanner across a full event // without needing the host to babysit them. func NewScannerJWTSigner(secret, issuer string, ttl time.Duration) (*ScannerJWTSigner, error) { if len(secret) < 32 { return nil, fmt.Errorf("jwt secret must be at least 32 bytes") } if ttl <= 0 { return nil, fmt.Errorf("scanner ttl must be positive") } return &ScannerJWTSigner{ secret: []byte(secret), issuer: issuer, ttl: ttl, parser: jwt.NewParser( jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(issuer), jwt.WithAudience(ScannerJWTAudience), jwt.WithExpirationRequired(), ), }, nil } func (s *ScannerJWTSigner) Issue(userID, eventID uuid.UUID, now time.Time) (string, time.Time, error) { exp := now.Add(s.ttl) claims := ScannerClaims{ UserID: userID, EventID: eventID, RegisteredClaims: jwt.RegisteredClaims{ Issuer: s.issuer, Subject: userID.String(), Audience: jwt.ClaimStrings{ScannerJWTAudience}, 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 returns the bound user + event. Maps token-expiry to // ErrExpiredJWT so the API can render a friendlier 410. func (s *ScannerJWTSigner) Parse(raw string) (*ScannerClaims, error) { claims := &ScannerClaims{} 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 || claims.UserID == uuid.Nil || claims.EventID == uuid.Nil { return nil, ErrInvalidJWT } return claims, nil }