package api import ( "context" "errors" "net/http" "strings" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/auth" ) // Scanner JWT integration — Tier 2 Block H follow-up. // // The host's desktop mints a scoped scanner ticket via POST // /events/{id}/scanner-ticket and renders the magic URL into a QR. A // door volunteer scans that with their phone camera, lands on /scanner // with ?token=, and the page uses the token as a Bearer for the // three check-in endpoints below. No second login required. // // The scanner JWT is HS256-signed with the same platform secret as the // session token but carries Audience="scanner". `requireAuth` rejects // audience=scanner; `requireAuthOrScanner` accepts either, and on a // scanner token stamps the URL-event-id constraint into the request // context so the handler can verify the path event matches before // touching the database. type scannerCtxKey int const scannerEventCtxKey scannerCtxKey = iota // scannerEventFromContext returns the event_id the bearer scanner token // is scoped to (if any). On a regular session token this returns // uuid.Nil, false — the handler should fall back to its normal role // check. On a scanner token it returns the event the token was minted // against and the handler must 403 if the request's path event differs. func scannerEventFromContext(ctx context.Context) (uuid.UUID, bool) { v, ok := ctx.Value(scannerEventCtxKey).(uuid.UUID) if !ok || v == uuid.Nil { return uuid.Nil, false } return v, true } // requireAuthOrScanner is the middleware applied to the three check-in // endpoints. A bearer token can be either: // // - a normal session JWT (no audience) — usual host/collaborator flow, // - a scanner JWT (Audience=scanner) — scoped to one event_id. // // Either way it sets userIDCtxKey so downstream handlers can call // hostFromContext. Scanner tokens additionally set scannerEventCtxKey; // handlers that read it MUST verify the URL event matches before doing // anything that mutates state. func requireAuthOrScanner(signer *auth.JWTSigner, scanner *auth.ScannerJWTSigner) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") if !strings.HasPrefix(h, "Bearer ") { writeError(w, http.StatusUnauthorized, "missing bearer token") return } raw := strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) // Try scanner first — the audience constraint means a session // token will fail this cheaply and we fall through to the // normal signer. if scanner != nil { if claims, err := scanner.Parse(raw); err == nil { ctx := context.WithValue(r.Context(), userIDCtxKey, claims.UserID) ctx = context.WithValue(ctx, scannerEventCtxKey, claims.EventID) next.ServeHTTP(w, r.WithContext(ctx)) return } else if errors.Is(err, auth.ErrExpiredJWT) { // If the audience matched but the token expired, surface // the friendlier expired message. Detect by re-parsing // without validation? Cheaper: if the normal signer can't // parse it either, treat as expired-scanner so the door // volunteer's phone gets a clear "ask the host for a // fresh link" prompt. if _, sessionErr := signer.Parse(raw); sessionErr != nil { writeError(w, http.StatusUnauthorized, "scanner link expired — ask the host for a new one") return } } } claims, err := signer.Parse(raw) if err != nil { if errors.Is(err, auth.ErrExpiredJWT) { writeError(w, http.StatusUnauthorized, "token expired") return } writeError(w, http.StatusUnauthorized, "invalid token") return } // Defence in depth: a session token must not carry the scanner // audience. requireAuth enforces the same; mirror it here. for _, aud := range claims.Audience { if aud == auth.ScannerJWTAudience { writeError(w, http.StatusUnauthorized, "scanner token cannot be used here") return } } ctx := context.WithValue(r.Context(), userIDCtxKey, claims.UserID) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // requireScannerEventMatch is the per-handler guard. After a check-in // handler has parsed its path event id, it calls this to confirm: // - if the request used a scanner JWT, the JWT's event matches the URL, // - if it didn't (regular session), this is a no-op. // // The role check (requireRole) is still applied for regular sessions // inside the handler; for scanner JWTs the role gate is bypassed because // the host already proved they were an editor when they minted the // ticket — passing that authority onto the door volunteer is the // entire point of the magic link. func requireScannerEventMatch(w http.ResponseWriter, r *http.Request, pathEventID uuid.UUID) bool { scoped, ok := scannerEventFromContext(r.Context()) if !ok { return true } if scoped != pathEventID { writeError(w, http.StatusForbidden, "scanner link is scoped to a different event") return false } return true }