Jack BeNimble
Jack BeNimble

Reputation: 36663

How to convert spring boot app with Okta to read okta groups

A while back, I created a spring book application that uses Okta as the OIDC provider. It's based on a previous version of this great example by Matt Raible.

What I'm trying to do now is obtain the group or roles for the logged in user.

Matt responded to Spring Boot / Okta - how to retrieve the users groups saying I should add claims to Okta interface, which I've done.

He qualified it, saying

Then, if you're using the Okta Spring Boot starter, your groups will automatically be converted to Spring Security authorities.

I converted to Spring Boot starter kit, with the following steps:

  1. Added the starter kit to pom.xml

  2. changed application properties to use variables from the starter kit as follows:

    #okta spring boot starter kit
    
    okta.oauth2.issuer=https://dev-xxxx.okta.com/oauth2/default
    
    okta.oauth2.audience=api://default
    
    okta.oauth2.groupsClaim=groups
    
    #okta spring boot starter kit test
    
    okta.oauth2.clientId=xxxxxxxxxxxx
    
    okta.oauth2.clientSecret=xxxxxxxxxxxxxxxxx
    

And it works as it did before.

However, when I print the Authorities, I still don't see the groups. The only authority I see is this:

Authority: ROLE_USER Username: [email protected]

Heres the code which prints the authorities:

Collection<? extends GrantedAuthority> authorities = principal.getAuthorities();
for (GrantedAuthority authority : authorities) {
    System.out.println("Authority: " + authority.getAuthority());
}

Is there anything I'm doing wrong here?

Here's an image of the claims: Claims

Upvotes: 0

Views: 1009

Answers (3)

Lin Byrne
Lin Byrne

Reputation: 11

I can see that this has been answered already but I wanted to add my experience since I had the same symptoms.

The Okta Spring Boot Starter will map the authorities if they are present in the token, but if you are using Auth0 as an IDP then the role will not come through in the groups attribute of the JWT, or at least I couldn't make it show up. Instead I added it manually as described here, then I needed to update my application.yml to update okta.oauth2.groupsClaim to be the identifier I used in the action.

This caused the spring boot starter to correctly read the role and add it as a GrantedAuthority on the user.

In my code I overrode the userInfoEndpoint to add my own context to the user, which meant that I needed to grab the GrantedAuthorities manually

I did this by adding

    @Autowired
    @Qualifier("groupClaimsAuthoritiesProvider")
    AuthoritiesProvider groupClaimsAuthoritiesProvider;

and in my userInfoEndpoint method grabbed the granted authorities using:

Collection<? extends GrantedAuthority> authorities = groupClaimsAuthoritiesProvider.getAuthorities(oidcUser, userRequest);

Hope this helps.

Upvotes: 1

ch4mp
ch4mp

Reputation: 12629

Some OAuth2 backround

A resource server (REST API authorizing requests using Authorization header with Bearer token) is interested in access tokens, not ID tokens which are intended to be used by OAuth2 clients. So you should add your private claims to access tokens if you intend to use it on a resource server (not ID tokens like you did before taking the screenshot in your question).

Spring applications configured as OAuth2 clients with oauth2Login are secured with session cookies (not access tokens) and retrieve user details from ID token (or userinfo endpoint when ID token is not available). Only in that case does it make sense to enrich ID token. Use case can be applications with server-side rendered UI (Thymeleaf, JSF, etc.) or spring-cloud-gateway configured as BFF (relay used in front of Javascript based applications, replacing session cookie with access tokens before forwarding requests to resource servers).

The tutorial you link depends on spring-boot-starter-oauth2-resource-server, but also on spring-boot-starter-oauth2-client. As it uses oauth2Login, the SecurityFilterChain is actually an OAuth2 client one and requests are secured with sessions, not access tokens. This is not a recommended way to configure REST APIs: stateless resource servers scale much better (sessions use resources and you have to use something like Spring Session to share sessions across instances).

Configuring a REST API as an OAuth2 client (in which case you should remove spring-boot-starter-oauth2-resource-server from your dependencies) can be a way to implement the BFF pattern, but it is certainly better to use a separate lightweight layer (spring-cloud-gateway) in front of regular resource server APIs.

For more OAuth2 background, refer to my tutorials intro.

Spring resource server with authorities mapping

As an alternative to Okta Spring Boot starter, you could use mine which works with any OpenID Provider. All you need is opening one of your tokens with a tool like https://jwt.io to figure out the JSON path of claim(s) you want to use as source for Spring authorities.

Sample for a resource server (REST API secured with access token like in the tutorial you link), assuming that authorities are to be built from groups claim (under root node), adding ROLE_ prefix to use hasRole(...) rather than hasAuthority(...):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <!-- in a reactive application, use spring-boot-starter-webflux instead -->
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
    <version>7.1.3</version>
</dependency>
@Configuration
// In a reactive app, use @EnableReactiveMethodSecurity instead
@EnableMethodSecurity
public class OAuth2SecurityConfig {
}
issuer: https://dev-xxxx.okta.com/oauth2/default
# client-id and client-secret are not used by resource servers with JWT decoders

# defining origins is optional, it is used for CORS configuration
origins: http://localhost:3000

# Spring boot properties for resource servers are ignored (not adapted to multi-tenant scenarios)

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        # this is an array, you can add all the OpenID Providers you trust
        - iss: ${issuer}
          # optional: it adds an audience validator to JWT decoder
          aud: api://default
          # optional: sub is a default you can replace with something relevant to your domain
          username-claim: sub
          authorities:
          # this is an array, you can add as many claims as you like
          - path: $.groups
            prefix: ROLE_
          # for illustration purpose: map roles claims without altering it with a prefix
          - path: $.roles
          # for illustration again: map all roles claims nested in an object (under a "domain" in Okta or Auth0)
          - path: $.*.roles
        resourceserver:
          # routes defined there will be accessible to anonymous requests
          # those not listed will require requests to be authorized
          permit-all:
          - "/public/**"
          - "/actuator/health/readiness"
          - "/actuator/health/liveness"
          - "/v3/api-docs/**"
          # optional: fine grained CORS configuration (per path matcher)
          cors:
          - path: /**
            allowed-origin-patterns: ${origins}

As you can see in the code above, the only things specific to Okta are the issuer URI and JSON path to claims to extract authorities from. Switching to any other OIDC provider (Auth0, Keycloak, Amazon Cognito, etc.) is just a matter of editing this properties.

Spring client with authorities mapping

As wrote above, this should stand in a gateway configuration rather than in a REST API. See my BFF tutorial.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
    <version>7.1.3</version>
</dependency>
issuer: https://dev-xxxx.okta.com/oauth2/default
okta-client-id: change-me
okta-client-secret: change-me

spring:
  security:
    oauth2:
      client:
        provider:
          okta:
            issuer-uri: ${issuer}
        registration:
          okta-confidential-user:
            authorization-grant-type: authorization_code
            client-id: ${okta-client-id}
            client-secret: ${okta-client-secret}
            provider: okta
            scope: openid,profile,email,offline_access,roles

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${issuer}
          authorities:
          - path: $.groups
            prefix: ROLE_
          - path: $.roles
          - path: $.*.roles
        client:
          security-matchers: 
          - /**
          permit-all:
          - /login/**
          - /oauth2/**
          csrf: cookie-accessible-from-js
          oauth2-logout:
            okta-confidential-user:
              uri: ${issuer}/login/signout
              post-logout-uri-request-param: fromURI

This will configure an OAuth2 client with oauth2Login (and logout).

Upvotes: 0

Matt Raible
Matt Raible

Reputation: 8614

I thought that adding the groupsClaim property would automatically map values to authorities. Are you sure you're adding it to the ID token?

The following technique is what we use in JHipster, which uses Spring Security w/o the Okta starter.

Add a GrantedAuthoritiesMapper bean in your security configuration.

/**
 * Map authorities from "groups" or "roles" claim in ID Token.
 *
 * @return a {@link GrantedAuthoritiesMapper} that maps groups from
 * the IdP to Spring Security Authorities.
 */
@Bean
public GrantedAuthoritiesMapper userAuthoritiesMapper() {
    return (authorities) -> {
        Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

        authorities.forEach(authority -> {
            // Check for OidcUserAuthority because Spring Security 5.2 returns
            // each scope as a GrantedAuthority, which we don't care about.
            if (authority instanceof OidcUserAuthority) {
                OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;
                mappedAuthorities.addAll(SecurityUtils.extractAuthorityFromClaims(oidcUserAuthority.getUserInfo().getClaims()));
            }
        });
        return mappedAuthorities;
    };
}

The relevant methods from SecurityUtils are:

public static List<GrantedAuthority> extractAuthorityFromClaims(Map<String, Object> claims) {
    return mapRolesToGrantedAuthorities(getRolesFromClaims(claims));
}

@SuppressWarnings("unchecked")
private static Collection<String> getRolesFromClaims(Map<String, Object> claims) {
    return (Collection<String>) claims.getOrDefault("groups",
        claims.getOrDefault("roles",
        claims.getOrDefault(CLAIMS_NAMESPACE + "roles", new ArrayList<>())));
}

private static List<GrantedAuthority> mapRolesToGrantedAuthorities(Collection<String> roles) {
    return roles.stream()
        .filter(role -> role.startsWith("ROLE_"))
        .map(SimpleGrantedAuthority::new)
        .collect(Collectors.toList());
}

Upvotes: 1

Related Questions