Using IBM App ID roles with Spring Security

This is something I've been meaning to write for a long time. If you're already familiar with how to set up App ID in a Spring Boot application, feel free to skip to the fifth step. That's where the juicy parts are.

1. Creating a project

Let's start off by creating a new Spring Boot project. This link contains a Spring Boot project with the required dependencies: Spring Security, Spring Web and OAuth2 resource server.

2. Configuring App ID

Next, let's create service credentials in our App ID instance.

Notice that we gave it write permissions. You'll see why soon.

While we're here, let's also add the "http://localhost:8080/login" redirect URL in the authentication settings :

To make things easier, let's enable the Google provider in the "Identity Providers" tab. This way we can log in with an existing Google account. If you don't want to do this, it's also possible to create an account on Cloud Directory and log in with its credentials. Alternatively, you can alo use another identity provider.

3. Configuring Spring Security

Once that's taken care of, let's configure our Spring Boot project with the JWT issuer URL. Head over to application.properties and add it like this :

spring.security.oauth2.resourceserver.jwt.issuer-uri: https://eu-gb.appid.cloud.ibm.com/oauth/v4/our tenant ID

We'll finish this by adding a security configuration that determines which endpoints to protect and which to open up for public access. This is as simple as creating a SecurityConfiguration class that looks like this :

@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .antMatchers("/api/private").authenticated()
            .antMatchers("/api/public").permitAll()
            .and()
            .oauth2ResourceServer()
            .jwt()
        ;
    }
}

Now that the configuration is out of the way, we'll create a controller with "/public" and "/private" endpoints :

@RestController
@RequestMapping("/api")
public class ApiController {
    @GetMapping("/public")
    public String viewPublic() {
        return "This endpoint is public";
    }

    @GetMapping("/private")
    public String viewPrivate() {
        return "This endpoint is private";
    }
}

4. Testing the login process

The API we just made needs an App ID-issued access token, but it doesn't really care about the login process itself.

Getting an access token can be done in a couple of ways. For now, let's use the official App ID Javascript SDK to make a very simple login form in VueJS. To keep things simple, this form will only display the access token to the screen, then we'll use cURL to test it against our API. Make sure to follow the documentation I linked to create application credentials for this SPA since we'll be using them in the init() function call.

This form can be hosted anywhere, but for now let's just host it in the backend itself. We'll start by creating a login.html file in the resources/static directory. Inside this file, we'll include Vue and the App ID SDK. We'll then retrieve the tokens and display them in a textarea :

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
    <script src="https://unpkg.com/vue@next"></script>
    <script src="https://cdn.appid.cloud.ibm.com/appid-0.3.0.min.js"></script>
</head>
<body>
    <h1>Login to App ID</h1>
    <div id="login">
        <div v-if="isLoggedIn">
            <p><label for="access-token">Access Token </label></p>
            <p><textarea id="access-token">{{ accessToken }}</textarea></p>
            <p><label for="id-token">ID Token </label></p>
            <p><textarea id="id-token">{{ idToken }}</textarea></p>
        </div>
        <div v-if="isLoading">
            <p>...</p>
        </div>
        <div v-if="error">An error occurred : {{ error }}</div>
    </div>
    <script>
    const Login = {
      data() {
        return {
          accessToken: "",
          idToken: "",
          error: "",
          isLoading: false
        }
      },
      async mounted() {
        this.login();
      },
      computed: {
        isLoggedIn() {
          return this.accessToken !== "" && this.idToken !== "";
        }
      },
      methods: {
        async login() {
          this.error = "";
          this.accessToken = "";
          this.idToken = "";
          const appID = new AppID();
          await appID.init({
            clientId: 'Our client ID',
            discoveryEndpoint: 'Our discovery endpoint'
          });

          try {
            this.isLoading = true;
            const tokens = await appID.signin();
            this.accessToken = tokens.accessToken;
            this.idToken = tokens.idToken;
          } catch(e) {
            console.error(e);
            this.error = "Failed to login : " + e.message;
          } finally {
            this.isLoading = false;
          }
        }
      }
    }
    Vue.createApp(Login).mount("#login");
    </script>
</body>
</html>

Next, let's make a controller to render this page :

@Controller
@RequestMapping("/")
public class LoginController {
    Logger logger = LoggerFactory.getLogger(LoginController.class);

    @GetMapping("login")
    public String viewPublic() {
        return "login.html";
    }
}

Let's run the application and navigate to http://localhost:8080/api/public. It should display the "This endpoint is public" string. Now, if we go to http://localhost:8080/api/private instead, it should return a 401 error. That's because it needs an access token. To get it, we'll navigate to http://localhost:8080/login, the URL of the page we just created. As soon as we land on that page, it should open a popup inviting us to sign in :

The login form is created by App ID. Its appearance can be customized to some degree, but this will do.

Let's log in with our Google account (or whatever account you created) :

After logging in, the page will now display the access and ID tokens :

These are bearer tokens, so we'll include them in the Authorization header using the "Authorization: Bearer token" syntax. Using the access token, we'll try to access the protected /api/private endpoint with the command :

curl http://localhost:8080/api/private -H "Authorization: Bearer eyJhb..." -v

If everything goes well, we should be getting a 200 status along with the long awaited "This endpoint is private" message

5. Introducing roles

Now that we know that Spring Security is working as it should, it's time to handle role-based authorizations. First, we'll add a third endpoint called "/api/admin". It can be put right below the "/api/private" one in the ApiController class :

@RestController
@RequestMapping("/api")
public class ApiController {
    //...

    @GetMapping("/admin")
    public String viewAdmin() {
        return "This endpoint is for admins only";
    }
}

Next, we'll let the security configuration know that this endpoint requires an ADMIN role. In the SecurityConfiguration class, we can add this right below the /api/private endpoint configuration :

@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
            //...
            .antMatchers("/api/private").authenticated()
            .antMatchers("/api/admin").hasRole("ADMIN")
            //...
    }
}

Now if we restart the application, log in and copy the access token into the Authorization header, it should tell us that it's forbidden :

curl http://localhost:8080/api/admin -H "Authorization: Bearer eyJhb..." -v

Which makes sense, our user doesn't have the required permissions. To fix this, let's first create an ADMIN role in the App ID UI.

Once that's done, we'll assign the newly created role to our existing user :

A profile visit should confirm this :

To test this, we'll log out and log back in, copy the token and run same command again. If you've been following along, you'll be surprised to see that it still complains about it being forbidden. What gives?

Well, it turns out that Spring Security and App ID don't agree on where the roles are. To solve this, we have to instruct App ID to include the roles in the access token, then tell Spring Security how to extract the roles from the access token that App ID sends back after a successful login.

6. Injecting the roles into the JWT

By default, when a role gets assigned to a user on the IBM App ID interface, the access token doesn't reflect this change. For this to happen, we have to "inject" them in one of its claims. The method to follow is described in the "customizing tokens" section of the documentation. This can be done on the Swagger interface of your IBM Cloud region. To help you with the process, you can use the following bash script. It depends on curl and jq :

apikey=Your API key
tenant_id=Your tenant ID
region=Your region

echo "[1/2] Retrieving IAM token..."

curl -X POST \
  https://iam.cloud.ibm.com/identity/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d "grant_type=urn:ibm:params:oauth:grant-type:apikey&apikey=$apikey" \
  -s \
  -Lo token.json

echo Done ! The tokens ars stored in token.json

iam_token=$(cat token.json | jq -r .access_token)

echo "[2/2] Injecting roles claim in tokens..."
curl -X PUT -s \
https://$region.appid.cloud.ibm.com/management/v4/$tenant_id/config/tokens -O configuration.json \
    -H "Authorization: Bearer $iam_token" \
    -H 'Content-Type: application/json' \
    -d '{
      "access": {
        "expires_in": 3600
      },
      "refresh": {
        "enabled": true,
        "expires_in": 2592000
      },
      "anonymousAccess": {
        "enabled": true,
        "expires_in": 2592000
      },
      "accessTokenClaims": [
        {
          "source": "roles",
          "sourceClaim": "name",
          "destinationClaim": "authorities"
        }
      ],
      "idTokenClaims": [
        {
          "source": "roles",
          "sourceClaim": "name",
          "destinationClaim": "authorities"
        }
      ]
    }'

echo Done !

Make sure to fill in the credentials where applicable. Remember why we gave them write permissions in the first step ? This is why. If the credentials only have read permissions, then the script above would fail with a 403 error. This took me a while to find out.

To make sure that the roles were correctly injected, we log in again, then paste the resulting access token in the JWT.io debugger to inspect its contents :

This confirms that the ADMIN role is in the "authorities" claim. Noice.

7. Extracting the roles in SecurityConfiguration

This can be achieved by providing a custom JWT authentication converter to the security configuration. In this converter, we'll tell it that the roles are in the "authorities" claim that we added in the previous step :

    protected void configure(HttpSecurity http) throws Exception {
        http
            //...
            .oauth2ResourceServer()
            .jwt()
            .jwtAuthenticationConverter(jwtAuthenticationConverter());
    }

    Converter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        converter.setAuthoritiesClaimName("authorities");
        converter.setAuthorityPrefix("");
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(converter);
        return jwtAuthenticationConverter;
    }

Note the presence of a setAuthorityPrefix call. If we don't do this, Spring Security will automatically give it the "ROLE_" prefix, and we would have to start using ROLE_ADMIN throughout the code.

At last, if we visit the /api/admin endpoint and log in, we're greeted by the following message :

Conclusion

I hope this clears the confusion surrounding role handling in IBM App ID and Spring Security. I'll publish the code on Github as soon as I can. Thanks for reading !

Edit: done.

Commentaires

Posts les plus consultés de ce blog

Writing a fast(er) youtube downloader

Decrypting .eslock files

My experience with Win by Inwi