Search code examples
swaggerswagger-uiopenapispring-authorization-serverspring-resource-server

Spring Resource Server (OAuth2.1) with OpenAPI/Swagger-UI


Background: I have 2 applications:

  1. Spring Authorizarion Server which authenticates the user via the authorization_code flow. Another scenario also with PKCE. http://192.168.0.4:8001
  2. Spring Resource Server, from which performs HTTP.GET, HTTP.POST, HTTP.PATCH, HTTP.DELETE for accounts which accepts a token from the Spring Authorization Server. The Resource Server is the one with the OpenAPI/Swagger Dependency. http://192.168.0.4:8002

I have noted that during the redirect process the HTTP.POST /oauth2/token Method from the redirect javascript doesn't attach some properties. i.e Cookie and some headers (Basic Auth Code/Key). That could be the root of the issues. These are required by the Spring OAuth Server to maintain state.

Resource Server:

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.3'
    id 'io.spring.dependency-management' version '1.1.3'
    id 'org.hibernate.orm' version '6.2.7.Final'
    id 'org.graalvm.buildtools.native' version '0.9.24'
}

group = 'io.resource'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'io.micrometer:micrometer-tracing-bridge-brave'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:postgresql'
}

tasks.named('test') {
    useJUnitPlatform()
}

hibernate {
    enhancement {
        enableAssociationManagement = true
    }
}


The Redirect script:

<!doctype html>
<html lang="en-US">
<head>
    <title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
<script>
    'use strict';

    function parseQueryParams(query) {
        let params = {};
        query.split('&').forEach(function(part) {
            let item = part.split('=');
            params[item[0]] = decodeURIComponent(item[1]);
        });
        return params;
    }

    function run() {
        let oauth2 = window.opener.swaggerUIRedirectOauth2;
        let sentState = oauth2.state;
        let redirectUrl = oauth2.redirectUrl;
        let qp = window.location.hash.substring(1) || location.search.substring(1);

        let params = parseQueryParams(qp);
        let isValid = params.state === sentState;

        let errorCb = function(message) {
            oauth2.errCb({
                authId: oauth2.auth.name,
                source: "auth",
                level: "error",
                message: message
            });
        };

        if (['accessCode', 'authorizationCode', 'authorization_code'].includes(oauth2.auth.schema.get("flow")) && !oauth2.auth.code) {
            if (!isValid) {
                errorCb("Authorization may be unsafe, passed state was changed in server. Passed state wasn't returned from auth server.");
                return;
            }
            if (params.code) {
                delete oauth2.state;
                oauth2.auth.code = params.code;
                oauth2.callback({ auth: oauth2.auth, redirectUrl: redirectUrl });
            } else {
                errorCb(params.error ? `[${params.error}]: ${params.error_description || 'no accessCode received from the server'}. ${params.error_uri || ''}` : "[Authorization failed]: no accessCode received from the server");
            }
        } else {
            oauth2.callback({ auth: oauth2.auth, token: params, isValid: isValid, redirectUrl: redirectUrl });
        }
        window.close();
    }

    window.addEventListener('DOMContentLoaded', run);
</script>
</body>
</html>

OpenAPI/Swagger-UI Config:

package io.resource.account.spring.config.swagger;

import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.OAuthFlow;
import io.swagger.v3.oas.annotations.security.OAuthFlows;
import io.swagger.v3.oas.annotations.security.OAuthScope;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;
import java.util.Collections;

@Configuration
@SecurityScheme(
        name = "security_auth",
        type = SecuritySchemeType.OAUTH2,
        flows = @OAuthFlows(
                authorizationCode = @OAuthFlow(
                        authorizationUrl = "${springdoc.oAuthFlow.authorization-url}",
                        tokenUrl = "${springdoc.oAuthFlow.token-url}",
                        scopes = {
                                @OAuthScope(name = "account.create", description = "Create account"),
                                @OAuthScope(name = "account.read", description = "Read account"),
                                @OAuthScope(name = "account.update", description = "Update account"),
                                @OAuthScope(name = "account.delete", description = "Delete account")
                        })))
public class OpenAPIConfig {

    @Bean
    public OpenAPI gateWayOpenApi() {
        return new OpenAPI().info(new Info().title("Accounts Service")
                        .description("Accounts Microservice Swagger API")
                        .version("v1.0.0")
                        .contact(new Contact()
                                .name("Accounts Dev Team")
                                .email("[email protected]")))
                .addSecurityItem(new SecurityRequirement()
                        .addList("bearer-jwt", Arrays.asList("account.create", "account.read", "account.update", "account.delete"))
                        .addList("bearer-key", Collections.emptyList()));
    }
}

Resource Server Config:

package io.resource.account.spring.config.security;

import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;

@Log4j2
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class ResourceServerConfig {

    // @formatter:off
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

        httpSecurity
                .authorizeHttpRequests(req -> req.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll())
                .authorizeHttpRequests(req -> req.requestMatchers(HttpMethod.GET, "/auth").fullyAuthenticated())
                .authorizeHttpRequests(req -> req.requestMatchers(HttpMethod.POST, "/accounts").hasAuthority("SCOPE_account.create"))
                .authorizeHttpRequests(req -> req.requestMatchers(HttpMethod.GET, "/accounts/**", "/accounts").hasAuthority("SCOPE_account.read"))
                .authorizeHttpRequests(req -> req.requestMatchers(HttpMethod.PATCH, "/accounts/**").hasAuthority("SCOPE_account.update"))
                .authorizeHttpRequests(req -> req.requestMatchers(HttpMethod.DELETE, "/accounts/**").hasAuthority("SCOPE_account.delete"))
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
//                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
                .authorizeHttpRequests(req -> req.requestMatchers(
                                "/v3/api-docs/**",
                                "/swagger-ui/**",
                                "/swagger-ui**",
                                "/api-docs-ui.html",
                                "/oauth2-redirect.html",
                                "/swagger-ui/oauth2-redirect.html",
                                "/public/**",
                                "/api-docs",
                                "/api-docs/**",
                                "/webjars/**",
                                "/swagger-resources/**",
                                "/actuator/**")
                        .permitAll());

        return httpSecurity.build();
    }
    // @formatter:on
    
}

I tested the authorization_code flow (& with PKCE) via postman successfully, but I've had no luck with Swagger-UI upon retrieving the authorization_code from the Auth-Server. When attempting to use the authorization code to retrieve the access_token, I then encountered the following error:

Redirect is not allowed for a preflight request Error

Access to fetch at 'http://192.168.0.4:8001/oauth2/token' from origin 'http://192.168.0.4:8002' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request. blocked by CORS policy error

I was expecting to get the access_token from the authorization server:

curl --location 'http://192.168.100.102:8001/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Basic Y2xpZW50OnNlY3JldA==' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=client' \
--data-urlencode 'redirect_uri=http://192.168.0.4:8002/swagger-ui/oauth2-redirect.html' \
--data-urlencode 'code=S-yW9-fNexepByQEvWnr5MLMSO8YAtX1npBp7VUEF3FAo-YqljVbVGGuf1eIa8kpJnviZXGzsgM59HJlULJc5nHI1blWbcxG7xINm7FpVkcG0zxQKdWBUSxsADy23bKD'

Solution

  • CORS is a common issue and many stack overflow questions deal with it. Having said that, if you need to allow pre-flight requests from pages served by the the resource server (Swagger UI), you'll need to configure CORS with Spring Security. The guide How-to: Authenticate using a Single Page Application with PKCE contains an example configuration you can use to get started.