fix: User principal was discarded by the resource server re-authentication

The saas chain authenticates bearer requests twice:
SupabaseAuthenticationFilter builds an EnhancedJwtAuthenticationToken
with the resolved User principal, but BearerTokenAuthenticationFilter
(oauth2ResourceServer) then re-authenticates the same token through the
static toAuthentication converter and overwrites the SecurityContext
with a token whose principal is the raw Jwt - so storage endpoints kept
returning 401 "Unsupported user principal" despite the principal fix.

Carry the User across in the converter: when the context already holds
an EnhancedJwtAuthenticationToken for the same subject with a User
principal, attach that User to the converter-built token. No extra DB
lookups; anonymous sessions and API-key auth unchanged. Covered by new
unit tests (carry, no-context, subject mismatch).
This commit is contained in:
Anthony Stirling
2026-06-10 14:37:12 +01:00
parent 5b412c0fed
commit 90bda6b4b4
2 changed files with 63 additions and 1 deletions
@@ -23,8 +23,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
@@ -44,6 +46,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.service.CreditService;
@@ -333,6 +336,16 @@ public class SupabaseSecurityConfig {
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(",")));
}
return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId);
// BearerTokenAuthenticationFilter overwrites the context SupabaseAuthenticationFilter
// built; carry its resolved User across so instanceof-User authorization keeps working.
User user = null;
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
if (existing instanceof EnhancedJwtAuthenticationToken enhanced
&& supabaseId != null
&& supabaseId.equals(enhanced.getSupabaseId())
&& enhanced.getPrincipal() instanceof User existingUser) {
user = existingUser;
}
return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId, user);
}
}
@@ -9,17 +9,25 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.security.SupabaseSecurityConfig.SupabaseTokenValidator;
/** Unit tests for the JWT-claim → Spring authorities mapping. */
class SupabaseSecurityConfigTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void anonymousJwtGetsLimitedApiUserRole() {
Jwt jwt = jwtWith(true, null, null, null, List.of());
@@ -75,6 +83,47 @@ class SupabaseSecurityConfigTest {
.doesNotContain("ROLE_", "PERM_");
}
@Test
void carriesUserPrincipalFromContextBuiltByAuthFilter() {
Jwt jwt = jwtWith(false, "[email protected]", "authenticated", null, List.of());
User user = new User();
SecurityContextHolder.getContext()
.setAuthentication(
new EnhancedJwtAuthenticationToken(
jwt, List.of(), "[email protected]", jwt.getSubject(), user));
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
assertThat(auth.getPrincipal()).isSameAs(user);
}
@Test
void principalStaysJwtWithoutContextUser() {
Jwt jwt = jwtWith(false, "[email protected]", "authenticated", null, List.of());
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
assertThat(auth.getPrincipal()).isSameAs(jwt);
}
@Test
void ignoresContextUserForDifferentSubject() {
Jwt jwt = jwtWith(false, "[email protected]", "authenticated", null, List.of());
Jwt other = jwtWith(false, "[email protected]", "authenticated", null, List.of());
SecurityContextHolder.getContext()
.setAuthentication(
new EnhancedJwtAuthenticationToken(
other,
List.of(),
"[email protected]",
other.getSubject(),
new User()));
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
assertThat(auth.getPrincipal()).isSameAs(jwt);
}
private static Jwt jwtWith(
boolean anonymous,
String email,