package middleware import ( "context" "errors" "net/http" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/response" ) type ctxKey string const ( userIDKey ctxKey = "user_id" tokenVersionKey ctxKey = "tv" ) // RequireAuth is the middleware applied to every protected route. // // It pulls the JWT from the cookie, verifies signature + expiry, then asks // the UserRepo for the current token_version and rejects if the JWT carries // a stale version (password-change or forced logout invalidates older JWTs). // // On success it stuffs (user_id, token_version) into the request context; // handlers read them via UserID() / TokenVersion(). func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw := auth.Read(r) if raw == "" { response.Error(w, http.StatusUnauthorized, "not authenticated") return } claims, err := issuer.Parse(raw) if err != nil { response.Error(w, http.StatusUnauthorized, "invalid or expired session") return } u, err := users.FindByID(r.Context(), claims.UserID) if err != nil { if errors.Is(err, repository.ErrUserNotFound) { response.Error(w, http.StatusUnauthorized, "account no longer exists") return } response.Error(w, http.StatusInternalServerError, "auth check failed") return } if u.TokenVersion != claims.TokenVersion { response.Error(w, http.StatusUnauthorized, "session expired — please log in again") return } // Suspended accounts are locked out of every protected route, so a // ban takes effect immediately for active sessions too. if u.BannedAt != nil { response.Error(w, http.StatusForbidden, "this account has been suspended") return } ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // OptionalAuth is the soft-auth sibling of RequireAuth. Same logic, but // invalid / missing sessions don't reject the request — they just skip // the context population. Use it on routes that are reachable // anonymously but want to personalise the response when the viewer // happens to be signed in (e.g. `viewer_has_reshared` on a public // post). func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw := auth.Read(r) if raw == "" { next.ServeHTTP(w, r) return } claims, err := issuer.Parse(raw) if err != nil { next.ServeHTTP(w, r) return } u, err := users.FindByID(r.Context(), claims.UserID) if err != nil || u.TokenVersion != claims.TokenVersion { // Stale or invalid session — proceed anonymously rather // than failing the request. RequireAuth would 401 here. next.ServeHTTP(w, r) return } ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // UserID returns the authenticated user's id from the request context. // Returns "" if RequireAuth has not run. func UserID(ctx context.Context) string { if v, ok := ctx.Value(userIDKey).(string); ok { return v } return "" } // TokenVersion returns the JWT's token_version claim from context. func TokenVersion(ctx context.Context) int { if v, ok := ctx.Value(tokenVersionKey).(int); ok { return v } return 0 }