Spring Boot API – OAuth Integration

Background

In our previous post we explained how to run this blog’s final Java code sample and explained its main behaviours. Next we will explain the main  details of the OAuth integration.

Spring API Defaults

By default Spring Security uses a framework based approach to OAuth security. Add the spring-boot-starter-oauth2-resource-server and spring-security-starter-web dependencies to your project and configure the API in a fluent manner:

@Configuration
public class SecurityConfiguration {
    
	@Bean
	public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {

        http
            .securityMatcher(new AntPathRequestMatcher("/**"))
            .authorizeHttpRequests(authz -> authz
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(rs -> rs
                .jwt(jwt -> jwt
                    .jwkSetUri(myJwksUri)
                )
            )
        return http.build();
    }
}

You can also add an entry such as this to the application.properties file when you need to troubleshoot failures.

logging.level.org.springframework.security=DEBUG

When getting started with OAuth secured APIs you should use these defaults. Yet this blog’s Java API focuses on some deeper requirements.

Deeper Requirements

This blog’s Java API will focus on customizing Spring’s default behaviour in order to meet the following requirements:

Requirement Description
Standards Based We will use the same standards based design patterns for OAuth security across Node.js, .NET and Java APIs.
Best Security The jose4j library will enable the most up to date and specialised security algorithms when dealing with JWTs.
Extensible Claims APIs are in full control of the claims principal, to work around authorization server limitations or add values that should not be issued to access tokens.
Supportable Identity and error details will be captured and included in logs, and JSON error responses will be customizable.

In Java our API’s OAuth code will follow the same two phases that we used in Node.js and .NET APIs:

Task Description
JWT Access Token Validation Downloading a JSON Web Key Set and verifying received JWTs
API Authorization Creating a ClaimsPrincipal that includes useful claims, then applying them during authorization

Your Java Secured API?

This API’s security implementation is meant to be thought provoking, to provide techniques for taking finer control over claims and error handling during secured requests. Some of these may be of interest to readers.

The API contains quite a bit of plumbing code though, to make the API code feel the same across technology stacks. For your own solution you may be able to meet similar requirements with simpler code.

OAuth API Configuration

The API uses a JSON configuration file with the following OAuth settings, which are the same as those used by the final Node.js API:

{
  "oauth": {
    "issuer":                       "https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_CuhLeqiE9",
    "audience":                     "",
    "scope":                        "https://api.authsamples.com/investments",
    "jwksEndpoint":                 "https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_CuhLeqiE9/.well-known/jwks.json",
    "claimsCacheTimeToLiveMinutes": 15
  }
}

The meaning of each field is summarised in the following table:

Field Description
issuer The expected authorization server to be received in access tokens
audience The audience in JWT access tokens represents a set of related APIs
scope The business area for the API
jwksEndpoint The location from which jose4j will use token signing public keys
claimsCacheTimeToLiveMinutes The time for which extra claims, not present in access tokens, are cached

API Authorization

The API receives an access token with a payload of the following form. The scope of access is limited to investments data. The user’s business identity is a custom claim of manager_id, for a party who manages investments. Another custom claim for role is also issued to the access token:

The API receives the main claims by processing the access token JWT, then does some more advanced work to deal with additional claims in an extensible way. The goal is to set up the API’s business logic with the most useful claims principal.

Custom Authorization Filter

The HttpServerConfiguration class wires up our API’s custom OAuth behaviour. This returns a SecurityFilterChain class, which configures a custom resource server implementation:

@Bean
public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {

    var container = this.context.getBeanFactory();
    var authorizationFilter = new CustomAuthorizationFilter(container);

    http
            .securityMatcher(new AntPathRequestMatcher(ResourcePaths.ALL))
            .authorizeHttpRequests(authorize ->
                    authorize.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
                    .anyRequest().authenticated()
            )
            .addFilterBefore(authorizationFilter, AbstractPreAuthenticatedProcessingFilter.class)

            .cors(AbstractHttpConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable)
            .headers(AbstractHttpConfigurer::disable)
            .requestCache(AbstractHttpConfigurer::disable)
            .securityContext(AbstractHttpConfigurer::disable)
            .logout(AbstractHttpConfigurer::disable)
            .exceptionHandling(AbstractHttpConfigurer::disable)
            .sessionManagement(AbstractHttpConfigurer::disable);

    return http.build();
}

In this blog’s architecture, web specific concerns are not dealt with in APIs. Instead, APIs are designed to be callable equally from web and mobile clients. Therefore this blog’s SPA’s web security is managed by the following components, and Spring’s web security is disabled:

Component Description
Web Host Manages returning static content and recommended web headers to the browser
Backend for Frontend Manages OpenID Connect concerns such as logins and logouts, and issuing of application level cookies
API Gateway Deals with web security concerns during API requests, including CSRF, cookie and CORS handling

OAuth and Claims Code

A number of small classes are used to implement the desired behaviour from this blog’s API Authorization Design. The main work is to validate the JWT received in the HTTP request, then return a ClaimsPrincipal that is useful to the rest of the API’s code.

JWT Access Token Validation

The AccessTokenValidator class deals with direct calls to the authorization server and its main code is to validate access tokens using jose4j. This includes checking for the API’s required scope:

public JwtClaims execute(final String accessToken) {

    try {
        var builder = new JwtConsumerBuilder()
            .setVerificationKeyResolver(this.jwksResolver)
            .setJwsAlgorithmConstraints(
                AlgorithmConstraints.ConstraintType.PERMIT,
                this.configuration.getAlgorithm()
            )
            .setExpectedIssuer(this.configuration.getIssuer());

        if (StringUtils.hasLength(this.configuration.getAudience())) {
            builder.setExpectedAudience(this.configuration.getAudience());
        }

        var jwtConsumer = builder.build();
        var claims = jwtConsumer.processToClaims(accessToken);

        var scopes = ClaimsReader.getStringClaim(claims, "scope").split(" ");
        var foundScope = Arrays.stream(scopes).filter(s -> s.contains(this.configuration.getScope())).findFirst();
        if (!foundScope.isPresent()) {
            throw ErrorFactory.createClientError(
                    HttpStatus.FORBIDDEN,
                    ErrorCodes.INSUFFICIENT_SCOPE,
                    "The token does not contain sufficient scope for this API");
        }

        return claims;

    } catch (InvalidJwtException ex) {
        throw ErrorUtils.fromAccessTokenValidationError(ex, this.configuration.getJwksEndpoint());
    }
}

There are a number of responsibilities that the library implements for us:

Responsibility Description
JWKS Key Lookup Downloading token signing public keys from the Authorization Server’s JWKS endpoint
JWKS Key Caching Caching the above keys and automatically dealing with new lookups when the signing keys are recycled
Signature Checks Cryptographically verifying the JSON Web Signature of received JWTs
Field Checks Checking that the token has the correct issuer and audience, and that it is not expired

Claims Principal

The claims principal for the sample API deals with some custom fields shown here, which are explained further in the API Authorization Design:

Claim Represents
Scope The scope for the API, which in this blog will be a  high level business area of investments
Subject The user’s technical OAuth identity, generated by the authorization server
Manager ID The business identity for a user, and in my example a manager is a party who administers investment data
Role A role from which business permissions would be derived, about the level of access
Title A business title for the end user, which is displayed by frontend applications
Regions An array claim meant to represent a more detailed business rule that does not belong in access tokens

In code the ClaimsPrincipal class is represented like this:

public class ClaimsPrincipal implements AuthenticatedPrincipal {

    @Getter
    private final JwtClaims jwtClaims;

    @Getter
    private final ExtraClaims extraClaims;

    public ClaimsPrincipal(final JwtClaims jwtClaims, final ExtraClaims extraClaims) {
        this.jwtClaims = jwtClaims;
        this.extraClaims = extraClaims;
    }
}

It can then be injected into business focused classes and used for authorization. This is a little tricky, since Spring may resolve and create all request scoped dependencies at the start of an HTTP request, before the OAuth filter is executed. Therefore a holder object is injected:

public CompanyService(final CompanyRepository repository, final ClaimsPrincipalHolder claimsHolder) {
    this.repository = repository;
    this.claimsHolder = claimsHolder;
}

When the OAuth filter runs successfully, it update the holder object with the claims principal. When the controller logic runs, the claims principal can be resolved, and this works even if the request uses child threads. The API can then easily implement its authorization logic, using simple code:

private boolean isUserAuthorizedForCompany(final Company company) {

    var claims = (SampleClaimsPrincipal) this.claimsHolder.getClaims();

    var isAdmin = claims.getRole().equalsIgnoreCase("admin");
    if (isAdmin) {
        return true;
    }

    var isUser = claims.getRole().equalsIgnoreCase("user");
    if (!isUser) {
        return false;
    }

    var extraClaims = (SampleExtraClaims) claims.getExtraClaims();
    return Arrays.stream(extraClaims.getRegions()).anyMatch(ur -> ur.equals(company.getRegion()));
}

OAuth Middleware Customization

The OAuthAuthorizer class encapsulates the overall OAuth work and deals with injecting claims into the ClaimsPrincipal when needed. In Spring it is also possible to add extra claims in a custom JwtAuthenticationConverter.

public ClaimsPrincipal execute(final HttpServletRequest request) {

    String accessToken = BearerToken.read(request);
    if (accessToken == null) {
        throw ErrorFactory.createClient401Error("No access token was supplied in the bearer header");
    }

    var jwtClaims = this.tokenValidator.execute(accessToken);

    String accessTokenHash = DigestUtils.sha256Hex(accessToken);
    var extraClaims = this.cache.getExtraUserClaims(accessTokenHash);
    if (extraClaims != null) {
        return this.extraClaimsProvider.createClaimsPrincipal(jwtClaims, extraClaims);
    }

    extraClaims = this.extraClaimsProvider.lookupExtraClaims(jwtClaims);
    this.cache.setExtraUserClaims(accessTokenHash, extraClaims, ClaimsReader.getExpiryClaim(jwtClaims));
 
    return this.extraClaimsProvider.createClaimsPrincipal(jwtClaims, extraClaims);
}

This technique adds complexity and is best avoided in most APIs. Yet this type of design can prove useful if you  run into productivity problems, where many fine-grained authorization values are managed in the authorization server.

If so, this is one possible way to ensure a stable access token and avoid needing to frequently deploy APIs and the authorization server together. It keeps the claims principal useful to the API’s logic, and reduces the need for access token versioning.

OAuth Error Responses

The API implements this blog’s Error Handling Design, starting by handling invalid tokens in the standard way. We can simulate a 401 error by clicking our UI’s Expire Access Token button followed by the Reload Data button:

This results in an invalid access token being sent to the API, which returns an error response in its standard format:

Data Description
HTTP Status The appropriate HTTP status is returned
Payload A JSON error object containing code and message fields

In total the following security related HTTP status codes may be returned:

Status Description
401 Access token is missing, invalid or expired
403 The access token does not contain a required scope
404 A ‘not found for user‘ error is returned if a resource is requested that domain specific claims do not allow
500 A technical problem during OAuth processing, such as a network error downloading JWKS keys

A 500 error is shown below in an HTTP Proxy Tool, and this type of error includes additional fields to help enable fast problem resolution:

The client can then take various types of action based on HTTP status and error code returned. For server errors the UI displays details that may be useful for technical support staff:

Identity and API Logs

API logs include details about OAuth errors, which helps when there is a configuration problem:

{
  "id": "b3629a0d-fc73-46b7-a3ca-6115963e8e43",
  "utcTime": "2022-12-10T13:17:28",
  "apiName": "SampleApi",
  "operationName": "GetCompanyTransactions",
  "hostName": "WORK",
  "method": "GET",
  "path": "/investments/companies/2/transactions",
  "resourceId": "2",
  "clientApplicationName": "FinalSPA",
  "statusCode": 200,
  "errorCode": "invalid_token",
  "millisecondsTaken": 43,
  "millisecondsThreshold": 500,
  "correlationId": "b091ec8e-a1d0-dbf9-f764-012cc730c925",
  "sessionId": "004d32bc-9755-b50e-6315-5be09f277ebe",
  "errorData": {
    "statusCode": 401,
    "body": {
      "code": "invalid_token",
      "message": "Missing, invalid or expired access token"
    },
    "context": "JWT verification failed: Invalid signature."
  }
}

Once a JWT is validated, its generated subject claim, most commonly a UUID, is also written to logs. This can potentially help to track users in benign ways, for technical support purposes. This requires care, and is discussed further in the Effective API Logging post.

Where Are We?

We have implemented Java API Security in a requirements first manner, to implement behavior that is likely to be important for any OAuth secured API, regardless of technology.

Next Steps