Dale Ogilvie
Dale Ogilvie

Reputation: 231

How can a jwt protected resource server call userinfo?

The documentation at spring security is missing important detail. Our idp does not provide an introspection link, and our resource server is not a client in its own right. It receives JWT access tokens from the actual client, and "needs to know" details about the user associated with the access token.

In our case standard jwt processing gives us a useful start, but we need to fill out the authentication with the claims from userinfo.

How do we 1. get a baseline valid oauth2 authentication, 2. fill it out with the results of the userinfo call.

public class UserInfoOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private final OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
    private final WebClient rest = WebClient.create();

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal authorized = this.delegate.introspect(token);
        return makeUserInfoRequest(authorized);
    }
}

Current implementation using a converter:

@Configuration
public class JWTSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired JwtConverterWithUserInfo jwtConverter;


    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
        .cors()
        .and()
        .csrf().disable()
        .authorizeRequests(authz -> authz
                .antMatchers("/api/**").authenticated()
                .anyRequest().permitAll())
        .oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtConverter);

    }

}
@Configuration
public class WebClientConfig {


    /**
     * Provides a Web-Client Bean containing the bearer token of the authenticated user.
     */
    @Bean
    WebClient webClient(){

        HttpClient httpClient = HttpClient.create()
                .responseTimeout(Duration.ofSeconds(5))
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);

        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .filter(new ServletBearerExchangeFilterFunction())
                .build();
    }
}
@Component
@Log4j2
public class JwtConverterWithUserInfo implements Converter<Jwt, AbstractAuthenticationToken> {

    @Autowired WebClient webClient;

    @Value("${userinfo-endpoint}")
    String userinfoEndpoint;

    @SuppressWarnings("unchecked")
    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {

        String token = jwt.getTokenValue();

        log.debug("Calling userinfo endpoint for token: {}", token);


        String identityType = jwt.getClaimAsString("identity_type");

        Map<String,Object> userInfo = new HashMap<>();
        if ("user".equals(identityType)) {
            // invoke the userinfo endpoint
            userInfo =
                    webClient.get()
                    .uri(userinfoEndpoint)
                    .headers(h -> h.setBearerAuth(token))
                    .retrieve()
                    .onStatus(s -> s.value() >= HttpStatus.SC_BAD_REQUEST, response -> response.bodyToMono(String.class).flatMap(body -> {
                        return Mono.error(new HttpException(String.format("%s, %s", response.statusCode(), body)));
                    }))
                    .bodyToMono(Map.class)
                    .block();
            log.debug("User info Map is: {}",userInfo);
            // construct an Authentication including the userinfo

            OidcIdToken oidcIdToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims());
            OidcUserInfo oidcUserInfo = new OidcUserInfo(userInfo);
            List<OidcUserAuthority> authorities = new ArrayList<>();
            if (oidcIdToken.hasClaim("scope")) {
                String scope = String.format("SCOPE_%s", oidcIdToken.getClaimAsString("scope"));
                authorities.add(new OidcUserAuthority(scope, oidcIdToken, oidcUserInfo));
            }

            OidcUser oidcUser = new DefaultOidcUser(authorities, oidcIdToken, oidcUserInfo, IdTokenClaimNames.SUB);

            //TODO replace this OAuth2 Client authentication with a more appropriate Resource Server equivalent
            return new OAuth2AuthenticationTokenWithCredentials(oidcUser, authorities, oidcUser.getName());
        } else {
            List<SimpleGrantedAuthority> authorities = new ArrayList<>();
            if (jwt.hasClaim("scope")) {
                authorities.add(new SimpleGrantedAuthority(String.format("SCOPE_%s", jwt.getClaimAsString("scope"))));
            }
            return new JwtAuthenticationToken(jwt, authorities);
        }

    }
}
public class OAuth2AuthenticationTokenWithCredentials extends OAuth2AuthenticationToken {

    public OAuth2AuthenticationTokenWithCredentials(OAuth2User principal,
            Collection<? extends GrantedAuthority> authorities,
            String authorizedClientRegistrationId) {
        super(principal, authorities, authorizedClientRegistrationId);
    }

    @Override
    public Object getCredentials() {
        return ((OidcUser) this.getPrincipal()).getIdToken();
    }

}

Upvotes: 1

Views: 1016

Answers (1)

jzheaux
jzheaux

Reputation: 7812

Instead of a custom OpaqueTokenIntrospector, try a custom JwtAuthenticationConverter:

@Component
public class UserInfoJwtAuthenticationConverter implements Converter<Jwt, BearerTokenAuthentication> {
    private final ClientRegistrationRepository clients;
    private final JwtGrantedAuthoritiesConverter authoritiesConverter =
        new JwtGrantedAuthoritiesConverter();

    @Override
    public BearerTokenAuthentication convert(Jwt jwt) {
        // Spring Security has already verified the JWT at this point
        OAuth2AuthenticatedPrincipal principal = invokeUserInfo(jwt);
        Instant issuedAt = jwt.getIssuedAt();
        Instant expiresAt = jwt.getExpiresAt();
        OAuth2AccessToken token = new OAuth2AccessToken(
            BEARER, jwt.getTokenValue(), issuedAt, expiresAt);
        Collection<GrantedAuthority> authorities = this.authoritiesConverter.convert(jwt);
        return new BearerTokenAuthentication(principal, token, authorities);
    }

    private OAuth2AuthenticatedPrincipal invokeUserInfo(Jwt jwt) {
        ClientRegistration registration = 
            this.clients.findByRegistrationId("registration-id");
        OAuth2UserRequest oauth2UserRequest = new OAuth2UserRequest(
            registration, jwt.getTokenValue());
        return this.oauth2UserService.loadUser(oauth2UserRequest);
    }
}

And then wire into the DSL like so:

@Bean
SecurityFilterChain web(
    HttpSecurity http, UserInfoJwtAuthenticationConverter authenticationConverter) {
    http
        .oauth2ResourceServer((oauth2) -> oauth2
            .jwt((jwt) -> jwt.jwtAuthenticationConverter())
        );

    return http.build();
}

our resource server is not a client in its own right

oauth2-client is where Spring Security's support for invoking /userinfo lives and ClientRegistration is where the application's credentials are stored for addressing /userinfo. If you don't have those, then you are on your own to invoke the /userinfo endpoint yourself. Nimbus provides good support, or you may be able to simply use RestTemplate.

Upvotes: 2

Related Questions