From d29059e6fb34c1ee049a1eb2555032ef67ba615d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:44:22 +0100 Subject: [PATCH] fix: storage APIs 401'd valid Supabase sessions (principal type mismatch) FileStorageService.requireAuthenticatedUser and FolderService.requireAuthenticatedUser authorize via 'principal instanceof User', but EnhancedJwtAuthenticationToken extends JwtAuthenticationToken whose principal is the decoded Jwt - so every /api/v1/storage/* request 401'd for JWT users AFTER Spring Security had already authenticated them. This persistent 401-with-valid-session was the trigger feeding the frontend login loop. Attach the filter-resolved local User as the token principal for full accounts (User implements UserDetails, matching the form-login convention every shared instanceof check expects). Anonymous sessions keep the raw Jwt principal, preserving their existing exclusions. All other principal consumers verified safe: AuthenticationUtils checks instanceof User first, extractSupabaseId/CreditController/Team SecurityExpressions switch on the authentication type, not the principal. Co-Authored-By: Claude Opus 4.8 --- .../EnhancedJwtAuthenticationToken.java | 26 +++++++++++++++++++ .../SupabaseAuthenticationFilter.java | 10 ++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java index c67845319..679e285f0 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java +++ b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java @@ -6,6 +6,8 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import stirling.software.proprietary.security.model.User; + /** * JWT auth token that exposes the Supabase subject UUID and email alongside the standard claims, so * downstream code (audit, credit accounting) can avoid re-parsing the JWT every request. @@ -14,15 +16,39 @@ public class EnhancedJwtAuthenticationToken extends JwtAuthenticationToken { private final String supabaseId; private final String email; + private final User user; public EnhancedJwtAuthenticationToken( Jwt jwt, Collection authorities, String email, String supabaseId) { + this(jwt, authorities, email, supabaseId, null); + } + + public EnhancedJwtAuthenticationToken( + Jwt jwt, + Collection authorities, + String email, + String supabaseId, + User user) { super(jwt, authorities, email); this.email = email; this.supabaseId = supabaseId; + this.user = user; + } + + /** + * Shared proprietary/core code authorizes with {@code principal instanceof User} (e.g. {@code + * FileStorageService.requireAuthenticatedUser}, {@code FolderService}), which the default + * {@link JwtAuthenticationToken} principal — the decoded {@link Jwt} — can never satisfy, so + * every such endpoint 401'd valid Supabase sessions. When the filter resolved a local {@link + * User}, expose it as the principal so those checks behave exactly as under form login; fall + * back to the Jwt when no User was attached (anonymous sessions, converter-built tokens). + */ + @Override + public Object getPrincipal() { + return user != null ? user : super.getPrincipal(); } public String getSupabaseId() { diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 08cdfcf57..888a5773f 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -155,9 +155,17 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { User user = getOrCreateUser(jwt); + // Attach the resolved User as the token principal for full accounts so shared + // `principal instanceof User` authorization (storage services, account data) + // works under JWT auth. Anonymous sessions keep the raw Jwt principal and stay + // locked out of those User-gated APIs, matching pre-existing behaviour. EnhancedJwtAuthenticationToken authToken = new EnhancedJwtAuthenticationToken( - jwt, user.getAuthorities(), user.getUsername(), supabaseId); + jwt, + user.getAuthorities(), + user.getUsername(), + supabaseId, + isAnonymous(jwt) ? null : user); SecurityContextHolder.getContext().setAuthentication(authToken); // Hot path: runs on every authenticated request (>10 per page on a typical SPA),