javaspringspring-bootspring-securityoauth2resourceserver

Springboot Oauth2 Principal object


I use springboot3 with spring-boot-starter-oauth2-resource-server. I can receive Jwt object using @AuthenticatedPrincipal in controller and I have access to jwt data in method access-control annotations like @PreAuthorize:

@PreAuthorize("principal.claims['id']=='100'")
@GetMapping
public Jwt test(@AuthenticationPrincipal Jwt principal) {
    return principal;
}

Is there any way to deal with custom principal object in @PreAuthorize and @AuthenticationPrincipal like here:

@PreAuthorize("principal.id==100")
@GetMapping
public MyPrincipal test(@AuthenticationPrincipal MyPrincipal principal) {
    return principal;
}

MyPrincipal:

public class JwtPrincipal implements MyPrincipal {
  private final Integer id;
  public JwtPrincipal(Jwt jwt) {
    this.id = Integer.parseInt(jwt.getClaim("id"));
  }
  @Override
  public Integer getId() {
    return id;
  }}

I would like to add such ability if no easy way to implement it in current Spring Security implementation. From developer side it would be enough to add MyPrincipal, JwtPrincipal, and a bean:

@Bean
public JwtPrincipalConverter jwtPrincipalConverter() {
    return JwtPrincipal::new;
}

P.S. Correct answer from spring security author can be found here


Solution

  • You can't change the type of the JwtAuthenticationToken principal, and you can't change the default authentication converter return type. What you can change is this authentication converter.

    As a reminder, the contract for the authentication converter in a resource server with a JWT decoder is Converter<Jwt, AbstractAuthenticationToken>. So, you can use an authentication converter returning anything that extends AbstractAuthenticationToken.

    In your case, extend this AbstractAuthenticationToken (AbstractOAuth2TokenAuthenticationToken is probably the most adapted in the type tree), set your JwtPrincipal as principal, and write an authentication converter building the two.

    You may then either:

    http.oauth2ResourceServer(oauth2 -> 
        oauth2.jwt(resourceServer ->
            resourceServer.jwtAuthenticationConverter(jwt -> 
                new YourAuthenticationWithJwtPrincipal(jwt) )));
    

    Note that you can also (and probably should) define the authentication converter as a @Bean or @Component (like I do below) and inject it when defining the security filter chain (rather than inlining its definition as I did above).

    With just spring-boot-starter-oauth2-resource-server

    public interface MyPrincipal extends Principal, OAuth2Token {
      Integer getId();
    }
    
    @Data
    @RequiredArgsConstructor
    public class JwtPrincipal implements MyPrincipal {
      private final Jwt jwt;
      private final String principalClaimName;
    
      @Override
      public Integer getId() {
        return Integer.parseInt(jwt.getClaim("id"));
      }
    
      @Override
      public String getTokenValue() {
        return jwt.getTokenValue();
      }
    
      @Override
      public String getName() {
        return jwt.getClaimAsString(principalClaimName);
      }
    }
    
    @EqualsAndHashCode(callSuper = true)
    public class YourAuthentcationWithJwtPrincipal
        extends AbstractOAuth2TokenAuthenticationToken<JwtPrincipal>
        implements OAuth2AuthenticatedPrincipal {
      private static final long serialVersionUID = -171062889286337491L;
    
      public YourAuthentcationWithJwtPrincipal(Jwt jwt, String principalClaimName,
          Collection<GrantedAuthority> authorities) {
        super(new JwtPrincipal(jwt, principalClaimName), authorities);
      }
    
      @Override
      public Map<String, Object> getAttributes() {
        return getTokenAttributes();
      }
    
      @Override
      public Map<String, Object> getTokenAttributes() {
        return getToken().getJwt().getClaims();
      }
    
      @Override
      public String getName() {
        return getToken().getName();
      }
    }
    
    @Component
    public class YourAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
      private final JwtGrantedAuthoritiesConverter authoritiesConverter;
      private final String principalClaimName;
    
      public YourAuthenticationConverter(
          @Value("${spring.security.oauth2.resourceserver.jwt.authorities-claim-name:'scope'}") String authoritiesClaimName,
          @Value("${spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter:' '}") String authoritiesClaimDelimiter,
          @Value("${spring.security.oauth2.resourceserver.jwt.authority-prefix:'SCOPE_'}") String authorityPrefix,
          @Value("${spring.security.oauth2.resourceserver.jwt.principal-claim-name:'sub'}") String principalClaimName) {
        this.authoritiesConverter = new JwtGrantedAuthoritiesConverter();
        this.authoritiesConverter.setAuthoritiesClaimName(authoritiesClaimName);
        this.authoritiesConverter.setAuthoritiesClaimDelimiter(authoritiesClaimDelimiter);
        this.authoritiesConverter.setAuthorityPrefix(authorityPrefix);
        this.principalClaimName = principalClaimName;
      }
    
      @Override
      public AbstractAuthenticationToken convert(@NonNull Jwt jwt) {
        return new YourAuthentcationWithJwtPrincipal(
            jwt,
            principalClaimName,
            authoritiesConverter.convert(jwt));
      }
    }
    
    spring:
      security:
        oauth2:
          resourceserver:
            jwt:
              issuer-uri: ${issuer}
              authorities-claim-name: groups
              authorities-claim-delimiter: ","
              authority-prefix: "ROLE_"
              principal-claim-name: "preferred_username"
    

    With this setup, a SecurityFilterChain bean is still auto-configured by spring-boot-starter-oauth2-resource-server, the "official" Boot starter, which scans for a Converter<Jwt, AbstractAuthenticationToken>.

    Because it implements this interface and is decorated with @Component, YourAuthenticationConverter replaces the default authentication converter.

    With spring-addons-starter-oidc

    This starter I wrote primarily aims at easing security configuration, but it also provides a few alternate implementations. These are completely optional and not used by default, but can be of great help in your case:

    @Bean
    JwtAbstractAuthenticationTokenConverter authenticationConverter(
        SpringAddonsOidcProperties addonsProperties,
        Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> authoritiesConverter) {
      return jwt -> {
        final var token = new JwtPrincipal(jwt.getClaims(),
            addonsProperties.getOps().get(0).getUsernameClaim(), jwt.getTokenValue());
        return new OAuthentication<JwtPrincipal>(token, authoritiesConverter.convert(token));
      };
    }
    
    public interface MyPrincipal extends Principal {
      Integer getId();
    }
    
    public static class JwtPrincipal extends OpenidToken implements MyPrincipal {
      private static final long serialVersionUID = -7265502984357862154L;
    
      public JwtPrincipal(Map<String, Object> claims, String usernameClaim, String tokenValue) {
        super(claims, usernameClaim, tokenValue);
      }
    
      @Override
      public Integer getId() {
        return Integer.parseInt(getClaim("id"));
      }
    }
    
    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: ${issuer}
              username-claim: preferred_username
              authorities:
                # this is a JsonPath which, as a difference from the official starter,
                # allows to parse nested claims like Keycloak's realm roles or Auth0 private claims
              - path: $.realm_access.roles