0

In Spring Boot I have a JWT token generated with Keycloak which has some custom claims:

{
  ...
  custom_claim:123
}

I can add an Authentication parameter in my rest controller and I can see this attribute but its in a deeply nested location principal.context.token.otherclaims[0] in a class called RefreshableKeycloakSecurityContext. What is the best way to use this claim in PreAuthorization? I think what I want is to compare the claim in the token with a path parameter and return an error if they don't match.

@PreAuthorization("custom_claim=#my-path-parm")
Chan Guan Yu
  • 119
  • 1
  • 8
George
  • 1,021
  • 15
  • 32

1 Answers1

1

Libraries to use and avoid

You have an instance of RefreshableKeycloakSecurityContext in security context because you are using Keycloak adapters for Spring. You shouldn't: it is very deprecated and not even compatible with spring-boot 3.

Details for configuring security of a Spring app with spring-boot and Keycloak in this other answer

How to compare path-variable with token claim

You can have the Authentication instance be "auto-magically" injected as controller parameter. By default for resource-servers, you'll get a JwtAuthenticationToken with JWT decoder and BearerTokenAuthentication with introspection ("opaque" tokens).

@RequestMapping("/{machin}")
@PreAuthorize("#machin eq #auth.tokenAttributes['yourclaim']") 
public SomeDto controllerMethod(@PathVariable("machin") String machin, AbstractOAuth2TokenAuthenticationToken<?> auth) {
   ...
}

Private claims access simplification

In spring-security, claim-set is typed Map<String, Object>. Accessing claims value requires null checks and casting, which can make a lot of grunt code, specially for nested claims.

I suggest you write a custom Authentication instance, using AbstractAuthenticationToken as base class, to wrap private claims parsing and casting (claim values are Object). With spring-boot-starter-oauth2-resource-server you provide a jwtAuthenticationConverter for that:

http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwt -> new YourAuthenticationImpl(jwt, authoritiesConverter.convert(jwt)));

You might also provide a custom DSL to make security expressions more readable and, more importantly, easier to maintain: expressions definition would be at a single place instead of being spread accross controllers methods.

I have written a set of 3 tutorials which end with expressions like:

@GetMapping("/on-behalf-of/{username}")
@PreAuthorize("is(#username) or isNice() or onBehalfOf(#username).can('greet')")
public String getGreetingFor(@PathVariable("username") String username, ProxiesAuthentication auth) {
    return "Hi %s from %s!".formatted(username, auth.getName());
}

This tutorials will also teach you how to unit-test your security expressions, even with private claims and custom Authentication. The endpoint secured with preceeding expression is tested as follow:

@Test
@ProxiesAuth(
        authorities = { "AUTHOR" },
        claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"),
        proxies = { @Proxy(onBehalfOf = "ch4mpy", can = { "greet" }) })
void whenNotNiceWithProxyThenCanGreetFor() throws Exception {
    mockMvc.get("/greet/on-behalf-of/ch4mpy")
        .andExpect(status().isOk())
        .andExpect(content().string("Hi ch4mpy from Tonton Pirate!"));
}

@Test
@ProxiesAuth(
        authorities = { "AUTHOR", "NICE" },
        claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"))
void whenNiceWithoutProxyThenCanGreetFor() throws Exception {
    mockMvc.get("/greet/on-behalf-of/ch4mpy")
        .andExpect(status().isOk())
        .andExpect(content().string("Hi ch4mpy from Tonton Pirate!"));
}

@Test
@ProxiesAuth(
        authorities = { "AUTHOR" },
        claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"),
        proxies = { @Proxy(onBehalfOf = "jwacongne", can = { "greet" }) })
void whenNotNiceWithoutRequiredProxyThenForbiddenToGreetFor() throws Exception {
    mockMvc.get("/greet/on-behalf-of/greeted")
        .andExpect(status().isForbidden());
}

@Test
@ProxiesAuth(
        authorities = { "AUTHOR" },
        claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"))
void whenHimselfThenCanGreetFor() throws Exception {
    mockMvc.get("/greet/on-behalf-of/Tonton Pirate")
        .andExpect(status().isOk())
        .andExpect(content().string("Hi Tonton Pirate from Tonton Pirate!"));
}
ch4mp
  • 6,622
  • 6
  • 29
  • 49
  • I'm curious: what more do you expect from an answer to accept it? – ch4mp Dec 06 '22 at 15:19
  • Also, you should really go through the first 3 of the tutorials I linked, you'll save a lot of time and code: https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials – ch4mp Dec 06 '22 at 16:02