Implement Asgardeo Auth in Spring Boot with Role-Based Access Control (RBAC)
Hi guys, welcome to another tutorial. In this tutorial we are going to discuss how to implement Asgardeo auth to a Spring Boot REST API which acts as a Resource Server. Specifically, we are going to use Spring Web Flux as I think it’s more suited towards building REST APIs.
We will be using the result of the last tutorial in this series, where we implemented Asgardeo Login with Next.js 14. You can also follow it but if you want to skip it, you can just clone the final source code from here and get to work.
As always, you can find the source code for this tutorial by following the below link.
So before we jump in to the tutorial, let me discuss the architecture of the application we are building.
Application architecture
In the previous tutorial where we implemented Asgardeo login with Next.js, we completed the steps 1 to 6. However, I will give you a brief overview of what they are.
In step 1, the user clicks on the login button on the app. This initiates a login request to Asgardeo along with the client ID, which is step 2. Here the user is redirected to Asgardeo, and is shown the login interface where the user logs in to Asgardeo in step 3. Then, upon successful login, Asgardeo redirects the user to the Next.js client with an authorization code. This code is used by the client to request the access token from Asgardeo in steps 5 and 6.
You can see that from step 7 to 8, it’s a little weird right? Why do we give the access token to the user in step 7? That is actually to learn how to manually pass the access token with a resource request to the Spring Boot REST API so that we can see what’s going on. In a later tutorial, we will eliminate this step and have the Next.js client itself connect to the Spring Boot application via the fetch API. So in step 8, you send the request with the token, then the Spring Boot application will call the jwks
endpoint to get the public key (step 9) which the token is signed with from Asgardeo. Then it will decode the token, validate it, and process the request to finally end up at step 10.
As a bonus, we are also going to implement Role-Based Access Control (RBAC) to our application with the use of OpenId scopes.
Now that we have a good understanding of how the flow works, let’s get to work.
Getting the access token
To get an access token of a user, in the Next.js app, you can just print the received access token in the console by changing the following line in app/lib/auth.ts
.
import { NextAuthOptions } from 'next-auth';
export const authOptions: NextAuthOptions = {
providers: [
{
id: 'asgardeo',
name: 'Asgardeo',
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
issuer: process.env.OAUTH_ISSUER_URL,
userinfo: process.env.OAUTH_USERINFO_URL,
type: 'oauth',
wellKnown: process.env.OAUTH_WELL_KNOWN_URL,
authorization: {
params: { scope: 'openid profile' },
},
idToken: true,
checks: ['pkce', 'state'],
// here
profile(profile, token) {
console.log(token);
return {
id: profile.sub,
name: profile.given_name + ' ' + profile.family_name,
email: profile.username,
};
},
},
],
pages: {
signIn: '/auth/signin',
},
// debug: true,
};
Now, if you login with the application you will see the access token printed out in the console as follows.
TokenSet {
access_token: 'eyJ4NXQiOiJrbnRfaW0yZWhPb05wTmRmQjdqUm1weThpMzgiLCJraWQiOiJZMkl6WTJRd1lUTmtNamhpWXpFeE1EZG1OREE1WlRrMU1qVm1ZalUwWm1KbFpHWXlNRGxtT1dWa01qZzNOelZqTjJFeVpqWmtaRGxqTldNek5tRmpNUV9SUzI1NiIsInR5cCI6ImF0K2p3dCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiI4YjVhOTg0Mi0wZDYwLTRiYTEtYjVjOS0xYWI0ZjBjNTAzYTkiLCJhdXQiOiJBUFBMSUNBVElPTl9VU0VSIiwiaXNzIjoiaHR0cHM6XC9cL2FwaS5hc2dhcmRlby5pb1wvdFwvaGFzYXRoY2hhcnVcL29hdXRoMlwvdG9rZW4iLCJnaXZlbl9uYW1lIjoiVGVzdCIsImNsaWVudF9pZCI6IktrcE9oSFFTZHZHV3BfTXFQWVRfYlpUSU1kTWEiLCJhdWQiOiJLa3BPaEhRU2R2R1dwX01xUFlUX2JaVElNZE1hIiwibmJmIjoxNzEzODQ2NTg0LCJhenAiOiJLa3BPaEhRU2R2R1dwX01xUFlUX2JaVElNZE1hIiwib3JnX2lkIjoiYzk3NzgxOTQtY2Q1Yi00NzA2LTkwNmEtM2I3MzQ1MTAwODVjIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSIsImV4cCI6MTcxMzg1MDE4NCwib3JnX25hbWUiOiJoYXNhdGhjaGFydSIsImlhdCI6MTcxMzg0NjU4NCwiZmFtaWx5X25hbWUiOiJVc2VyIiwianRpIjoiZjU3OTEzMzItNjYyYi00ZDg1LWFiNTgtODRmYWU0MWFiMDI0IiwidXNlcm5hbWUiOiJ0ZXN0QHRlc3QuY29tIn0.PHWnlUqbo_hvF6Ev-4iMMis_a2VfSfpwcPZILenZJXRXpfQNNo_TNKPy7m7-_2dFukT7mSnNLJPn_YkHYeB6V7tRvLSBTAsnt_sVz5QQYqGmGSX09Hlgb-jms9YatUJ0Z8AeND0aBnbdl7rdSB6f9KHtsJGhi4UO_G-xwDJGixdEZ40gzLaP4XdQr6QFuddHRNZIuBFlYoAKZ_JfUfGTxSPl80mEENoai-jxQDQBKYetZdLbPIlCyXqvLv5nfFe9vwvOYCRT-Ti1DxfTDAR-O7tgFxdk_LCK3XmFkQsV2edkRb6GnR9CuhZ8BDqY9Kc4wo5eMu6dlK71KbJFsuPE5Q',
scope: 'openid profile',
id_token: 'eyJ4NXQiOiJrbnRfaW0yZWhPb05wTmRmQjdqUm1weThpMzgiLCJraWQiOiJZMkl6WTJRd1lUTmtNamhpWXpFeE1EZG1OREE1WlRrMU1qVm1ZalUwWm1KbFpHWXlNRGxtT1dWa01qZzNOelZqTjJFeVpqWmtaRGxqTldNek5tRmpNUV9SUzI1NiIsImFsZyI6IlJTMjU2In0.eyJpc2siOiI0NDNjYjgxYjkxMmRjOWE2MzE0NWJkYzQ2OTU0NDg5NmVhYWRmN2VkMTcxMmNiNTlkNmUxYjNiYjI0N2IxNDA1IiwiYXRfaGFzaCI6Ind5czlxbTJiSUVHUjVlVmhjUENkWUEiLCJzdWIiOiI4YjVhOTg0Mi0wZDYwLTRiYTEtYjVjOS0xYWI0ZjBjNTAzYTkiLCJhbXIiOlsiQmFzaWNBdXRoZW50aWNhdG9yIl0sImlzcyI6Imh0dHBzOlwvXC9hcGkuYXNnYXJkZW8uaW9cL3RcL2hhc2F0aGNoYXJ1XC9vYXV0aDJcL3Rva2VuIiwiZ2l2ZW5fbmFtZSI6IlRlc3QiLCJzaWQiOiJmYzIzYWIzMC0yY2IwLTQwMzgtOWQ1Ni1iNmNkZWNhMzJiZGIiLCJhdWQiOiJLa3BPaEhRU2R2R1dwX01xUFlUX2JaVElNZE1hIiwiY19oYXNoIjoiOVpDM1NLQ1ZLbVpESTNoNmZVbmFPUSIsIm5iZiI6MTcxMzg0NjU4NCwiYXpwIjoiS2twT2hIUVNkdkdXcF9NcVBZVF9iWlRJTWRNYSIsIm9yZ19pZCI6ImM5Nzc4MTk0LWNkNWItNDcwNi05MDZhLTNiNzM0NTEwMDg1YyIsImV4cCI6MTcxMzg1MDE4NCwib3JnX25hbWUiOiJoYXNhdGhjaGFydSIsImlhdCI6MTcxMzg0NjU4NCwiZmFtaWx5X25hbWUiOiJVc2VyIiwianRpIjoiNWRkYzY4OWEtMTI0Mi00NjlkLTg0ZTMtYzA5ZmMxZTdmODZjIiwidXNlcm5hbWUiOiJ0ZXN0QHRlc3QuY29tIn0.RYXWpNK6kJw1RcaQFWhkANPFZCaKOFyVEuwqIxtHBu5NeQxnXHNn_1e2L4lYCq41Kdo7Mc3KAKXvEz7br__YERVbzbj-IE58y19LAdGBmicTsCxi_SUObZ2h6FuCoKndPxKzmN63X7kpuuVIDisRKApyclDRwSuPzt8GT8HQ2ENoT-1m6x24Abrd624PvRLoAJW2xKkg7eu3PrXv3bbeaf_M3Zmh91IomMnf3lHRA-VAraDXdkOM9N4wQDXcWEATC7rGH57VjS3PVLeEOZqvhSx0b8hlaph35uR2N6MNFDkZv0OsloeeknMC0pk6PcLSNrg9AgwYB5vDvFCb98fx4Q',
token_type: 'Bearer',
expires_at: 1713850184,
session_state: 'fabc44c021f58a90129f04c015e2dd0df785c6b521149a1c76acf997d4acdbc5.0UKZvDLOX9MSHfE6ZN5jIw'
}
Now that we know how to obtain a token, we’ll start configuring an API resource in Asgardeo.
Configuring an API resource and the admin role
In this section, we are going to configure our Spring Boot app with Asgardeo as an API resource. So let’s dive in.
First, let’s create the Administrators
group. Once you login to the application, make sure you have at least two users, one who’s an admin and another who’s just a normal user.
Now you can head over to Groups tab and create a new group.
You can assign anyone to this group but make sure there’s at least one user who’s not in the Administrators
group.
You can also verify the users are indeed added to the group by navigating to Users->Groups
page.
Now, its’ time to create the roles in your Asgardeo application. For that you need to head to the Applications->Roles
page.
You can create a new role as follows.
Now, you need to assign this role to the Administrators
group. To do that, head over to Groups->Roles
page.
Okay! We are done with the roles. Now its’ time to register our Spring Boot application as an API resource.
Here, you can give any name for the identifier you want, but Asgardeo advises us to use the URI
.
In the Permissions
section, don’t forget to create a scope as admin
with the name Administrator
You can un-tick the Requires authorization option. This doesn’t affect our app for now.
Now you need to go to your API Authorization page in your application and authorize the API resource we just created.
Make sure to add the Administrator scope as well.
Now, the final step, which is to connect the Administrator
role in the Roles we created with this admin
scope. You can head over to the Roles
page in your application.
Right! Now we have completed the work on the Asgardeo side! Let’s build the application.
Creating the project
You can get the configured dependencies for the project with the following link.
Now that we have the app, let’s get to work.
Implementing the endpoints
So before we begin with anything, we can start implementing the controllers.
Go ahead and create a controller
package and create three classes AdminController
, PrivateController
, and PublicController
. What we are going to do is, we are going to build the app such that endpoints in PublicController
will be accessible to thepublic, endpoints in the PrivateController
accessible only to users with a valid access token, and endpoints in the AdminController
accessible only to those with both a valid access token and are having the admin
scope, as we configured RBAC in the Asgardeo API resource.
Let’s first begin with the PublicController
.
package com.hasathcharu.asgardeospring.controller;
@RestController
@RequestMapping("/api/public")
public class PublicController {
@GetMapping
public MessageResponse getPublicMessage(){
return new MessageResponse("This is a public route, anyone can access this.");
}
}
Here, I have used the @RequestMapping
to declare the endpoint as /api/public
, and the getPublicMessage()
serves any requests coming to the endpoint.
I have also created a dto
package which contains the MessageResponse
record
.
package com.hasathcharu.asgardeospring.dto;
public record MessageResponse(String message) { }
Now, let’s write the private controller.
package com.hasathcharu.asgardeospring.controller;
@RestController
@RequestMapping("/api/private")
public class PrivateController {
@GetMapping()
@ResponseStatus(HttpStatus.OK)
public MessageResponse getPrivateMessage(){
return new MessageResponse("This is a private route, only authenticated users can access this.");
}
@GetMapping("/user")
@ResponseStatus(HttpStatus.OK)
public UserResponse getUserMessage(Authentication authentication){
Jwt userDetails = (Jwt) authentication.getPrincipal();
System.out.println("User has authorities: " + authentication.getAuthorities());
return new UserResponse(userDetails.getClaim("given_name") + " " + userDetails.getClaim("family_name"), userDetails.getClaim("username"), userDetails.getClaim("scope"));
}
}
Here, I have another endpoint /api/private/user
which displays user information obtained from the JWT token.
The UserResponse
DTO record
is as follows.
package com.hasathcharu.asgardeospring.dto;
public record UserResponse(String name, String email, String scope) { }
Finally, here is the AdminController
.
package com.hasathcharu.asgardeospring.controller;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@GetMapping
@ResponseStatus(HttpStatus.OK)
public MessageResponse getAdminMessage(){
return new MessageResponse("This is an admin route, only admin users can access this.");
}
}
Implementing security
This is the final and the most important stage of the tutorial. Now we are going to configure this app as an OAuth2 Resource Server and implement auth with Asgardeo.
First, we need to declare some configurations in the application.properties
.
spring.application.name=asgardeo-spring
spring.security.oauth2.resourceserver.jwt.issuer-uri = https://api.asgardeo.io/t/<YOUR_ORG>/oauth2/token
spring.security.oauth2.resourceserver.jwt.jwk-set-uri = https://api.asgardeo.io/t/<YOUR_ORG>/oauth2/jwks
spring.security.oauth2.resourceserver.jwt.audiences=<CLIENT_ID>
logging.level.org.springframework.security=DEBUG
Here, we are setting some URIs
that can be obtained by visiting the Info page in your Asgardeo application. Also, don’t forget to put the client ID and your organization as well. As we discussed earlier, the jwks
endpoint is used to obtain the public key in order to decode the JWT token. The issuer
endpoint is used to validate the identity provider contained in the iss
claim of the JWT. This way, we know that the JWT is indeed generated by the identity provider. Without it, anyone could just create a JWT. Actually, having the issuer
URI is completely enough, but having a separate jwks
endpoint ensures that the Resource Server can startup even without the availability of the identity provider. As Asgardeo is unlikely to go down, we can even omit the jwks
endpoint.
Now, let’s create a config
package, and create our SecurityConfig
class.
package com.hasathcharu.asgardeospring.config;
@Configuration
public class SecurityConfig{
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwks;
@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.csrf(CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/public/**")
.permitAll()
.pathMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyExchange()
.authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtDecoder(customDecoder())));
return http.build();
}
private ReactiveJwtDecoder customDecoder() {
return NimbusReactiveJwtDecoder.withJwkSetUri(jwks)
.jwtProcessorCustomizer(customizer->customizer.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"))))
.build();
}
}
Do not forget to annotate the SecurityConfig
class with the @Configuration
annotation. Notice how I have allowed /api/public/**
paths to any request, and all others required to be authenticated through the SecurityWebFilterChain
. Moreover, I have also restricted the /api/admin/**
route only to users with the scope admin
.
Also, note that how I used a custom JwtDecoder
as JWTs generated by Asgardeo contains an exlusive typ
attribute at+jwt
as opposed to the norm of just having jwt
.
Trying it out
You can try out sending requests with the included requests.http
file, and configuring environment variables in the included http-client.env.json
file in the resources
directory.
Remember, when you want to get a token with the scope
admin
, you have to explicitly request the scope by editing theauthorization
config object in the/lib/auth.ts
in the Next.js client application.
import { NextAuthOptions } from 'next-auth';
export const authOptions: NextAuthOptions = {
providers: [
{
id: 'asgardeo',
name: 'Asgardeo',
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
issuer: process.env.OAUTH_ISSUER_URL,
userinfo: process.env.OAUTH_USERINFO_URL,
type: 'oauth',
wellKnown: process.env.OAUTH_WELL_KNOWN_URL,
// here
authorization: {
params: { scope: 'openid profile admin' },
},
idToken: true,
checks: ['pkce', 'state'],
profile(profile, token) {
console.log(token);
return {
id: profile.sub,
name: profile.given_name + ' ' + profile.family_name,
email: profile.username,
};
},
},
],
pages: {
signIn: '/auth/signin',
},
// debug: true,
};
Now, you can get two tokens with a non admin user, and an admin user, and paste the tokens in the env
file in the Spring Boot app, in their respective environments.
Then, configure the environment as user
or admin
according to your liking in the requests.http
in order to access the token.
Now, you can play around with the app!
Conclusion
In this tutorial, we were able to integrate Spring Boot with our Asgardeo application and you can extend this so that Next.js can make fetch API calls to the Spring Boot application to access protected resources. I will hopefully also make a tutorial of that in the future. Thank you so much for reading!
Sources
- https://wso2.com/asgardeo/docs/tutorials/secure-spring-boot-api/
- https://wso2.com/asgardeo/docs/references/authorization-policies-for-apps/
- https://wso2.com/asgardeo/docs/guides/api-authorization/
- https://docs.spring.io/spring-security/reference/reactive/oauth2/resource-server/jwt.html
- https://piraveenaparalogarajah.medium.com/secure-your-spring-boot-application-with-asgardeo-3e90e9f9aed4
- https://chinthakarukshan.medium.com/jwt-authentication-for-a-spring-boot-application-using-asgardeo-part-1-b9185ed22551