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 <[email protected]>
This commit is contained in:
Anthony Stirling
2026-06-10 13:44:22 +01:00
co-authored by Claude Opus 4.8
parent e7bbbb4702
commit d29059e6fb
2 changed files with 35 additions and 1 deletions
@@ -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<? extends GrantedAuthority> authorities,
String email,
String supabaseId) {
this(jwt, authorities, email, supabaseId, null);
}
public EnhancedJwtAuthenticationToken(
Jwt jwt,
Collection<? extends GrantedAuthority> 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() {
@@ -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),