Skip to main content
the auth layer

Spring Authorization Server: Base Configuration and Client Registration

5 min read Chapter 9 of 45

Spring Authorization Server: Base Configuration and Client Registration

The Assumption

Spring Authorization Server tutorials show a RegisteredClient with redirectUri("http://localhost:8080/callback") and move on. The assumption: redirect URI configuration is a deployment detail, not a security boundary.

Redirect URI validation is the primary defense against authorization code theft via open redirect. Every character matters. A trailing slash difference, a port number omission, or a query parameter allowance can turn your authorization endpoint into a token distribution service for attackers.

The Attack

Open redirect via redirect_uri manipulation. The authorization server’s authorize endpoint accepts a redirect_uri parameter. After the user authenticates and consents, the server redirects the browser to this URI with the authorization code appended as a query parameter.

If the authorization server validates redirect URIs loosely (prefix match, wildcard, or regex), an attacker can craft an authorization URL that redirects the code to their server:

https://auth.saas.example/oauth2/authorize?
  response_type=code&
  client_id=frontend-shell&
  redirect_uri=https://app.saas.example/callback/../../../attacker.example/steal&
  scope=openid&
  state=attacker-state&
  code_challenge=...&
  code_challenge_method=S256

If the server normalizes the path and the result still matches a registered prefix, the code goes to the attacker. The attacker then uses the code at the token endpoint. If PKCE is not enforced, the attacker gets tokens. If PKCE is enforced but the attacker also controls a legitimate client on the same authorization server, they can initiate their own PKCE flow and use the stolen code with their own verifier (this fails, but the attack against non-PKCE clients succeeds).

The defense: exact string match. No normalization. No prefix matching. No wildcards. The redirect_uri in the authorization request must be byte-for-byte identical to a registered redirect URI. Spring Authorization Server enforces this by default.

The Spec or Mechanism

RFC 6749 Section 3.1.2.2 requires that the authorization server compare the redirect_uri using “simple string comparison” as defined in RFC 3986 Section 6.2.1. This means no path normalization, no scheme-case-insensitive comparison, no query parameter reordering. Exact match.

Spring Authorization Server implements this in OAuth2AuthorizationCodeRequestAuthenticationValidator. The validator iterates over the client’s registered redirect URIs and performs String.equals() comparison. There is no pattern matching, no regex, no prefix extraction.

The AuthorizationServerSettings configures the endpoints:

@Bean
public AuthorizationServerSettings authorizationServerSettings() {
    return AuthorizationServerSettings.builder()
        .issuer("https://auth.saas.example")
        .authorizationEndpoint("/oauth2/authorize")
        .tokenEndpoint("/oauth2/token")
        .jwkSetEndpoint("/oauth2/jwks")
        .tokenRevocationEndpoint("/oauth2/revoke")
        .tokenIntrospectionEndpoint("/oauth2/introspect")
        .oidcUserInfoEndpoint("/userinfo")
        .build();
}

The SecurityFilterChain for the authorization server must be ordered before your resource server filter chain:

@Bean
@Order(0)
public SecurityFilterChain authorizationServerFilterChain(HttpSecurity http) throws Exception {
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

    http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
        .oidc(Customizer.withDefaults());

    http.exceptionHandling(exceptions -> exceptions
        .defaultAuthenticationEntryPointFor(
            new LoginUrlAuthenticationEntryPoint("/login"),
            new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
        )
    );

    return http.build();
}

The Implementation

Complete Base Authorization Server Configuration

@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    @Bean
    @Order(0)
    public SecurityFilterChain authServerFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .oidc(Customizer.withDefaults());

        http.exceptionHandling(exceptions -> exceptions
            .defaultAuthenticationEntryPointFor(
                new LoginUrlAuthenticationEntryPoint("/login"),
                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
            )
        );

        return http.build();
    }

    @Bean
    @Order(1)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/error").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
            )
            .build();
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("https://auth.saas.example")
            .build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository(
            PasswordEncoder passwordEncoder) {

        // Frontend Shell: browser-based SPA with BFF
        RegisteredClient frontendShell = RegisteredClient
            .withId(UUID.randomUUID().toString())
            .clientId("frontend-shell")
            .clientSecret(passwordEncoder.encode("frontend-shell-secret"))
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("https://app.saas.example/callback")
            .redirectUri("https://app.saas.example/silent-renew")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("tenant:read")
            .scope("tenant:write")
            .clientSettings(ClientSettings.builder()
                .requireProofKey(true)
                .requireAuthorizationConsent(true)
                .build())
            .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(5))
                .refreshTokenTimeToLive(Duration.ofHours(8))
                .reuseRefreshTokens(false)
                .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                .build())
            .build();

        // Mobile Client: native app with secure storage
        RegisteredClient mobileClient = RegisteredClient
            .withId(UUID.randomUUID().toString())
            .clientId("mobile-client")
            // Public client: no client_secret
            .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("com.saas.example.mobile://callback")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("tenant:read")
            .clientSettings(ClientSettings.builder()
                .requireProofKey(true) // Mandatory for public clients
                .requireAuthorizationConsent(false) // First-party app
                .build())
            .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(15))
                .refreshTokenTimeToLive(Duration.ofDays(30))
                .reuseRefreshTokens(false)
                .build())
            .build();

        // Internal service: client credentials only
        RegisteredClient paymentService = RegisteredClient
            .withId(UUID.randomUUID().toString())
            .clientId("payment-service")
            .clientSecret(passwordEncoder.encode("payment-service-secret"))
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .scope("internal:payment")
            .scope("internal:billing")
            .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(5))
                .build())
            .build();

        return new InMemoryRegisteredClientRepository(
            frontendShell, mobileClient, paymentService);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRSAKey();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    private RSAKey generateRSAKey() {
        try {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
            generator.initialize(2048);
            KeyPair keyPair = generator.generateKeyPair();

            RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
            RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

            return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("RSA key generation failed", e);
        }
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }
}

Client Registration Security Decisions

Each client registration encodes security decisions:

Frontend Shell (confidential client):

  • CLIENT_SECRET_BASIC: Authenticates at the token endpoint with HTTP Basic (client_id:client_secret in Authorization header). The secret proves the client’s identity, preventing code theft even if PKCE is somehow bypassed.
  • requireProofKey(true): PKCE mandatory. Defense-in-depth alongside the client secret.
  • requireAuthorizationConsent(true): User sees what scopes the client requests. Prevents scope creep.
  • Access token TTL 5 minutes: Limits exposure window if token is intercepted.
  • reuseRefreshTokens(false): Refresh token rotation. Each refresh invalidates the previous token. Replay detection possible.

Mobile Client (public client):

  • ClientAuthenticationMethod.NONE: No client secret. Mobile apps cannot keep secrets (decompilation reveals them). PKCE is the sole code-exchange defense.
  • requireProofKey(true): Non-negotiable for public clients. Without PKCE and without a client secret, the authorization code is exchangeable by anyone who intercepts it.
  • Longer token TTL (15 minutes access, 30 days refresh): Mobile users do not tolerate frequent re-authentication. The refresh token is stored in platform-secure storage.

Payment Service (machine client):

  • CLIENT_CREDENTIALS only: No user interaction. The service authenticates with its own credentials.
  • Short access token TTL (5 minutes): Service tokens are used immediately and discarded.
  • No refresh token: Client credentials grant does not issue refresh tokens. The service requests a new token when the current one expires.

The Verification

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ClientRegistrationSecurityTest {

    @Autowired
    private RegisteredClientRepository clientRepository;

    @Test
    void publicClientRequiresPKCE() {
        RegisteredClient mobile = clientRepository.findByClientId("mobile-client");
        assertThat(mobile).isNotNull();
        assertThat(mobile.getClientSettings().isRequireProofKey()).isTrue();
        assertThat(mobile.getClientAuthenticationMethods())
            .containsExactly(ClientAuthenticationMethod.NONE);
    }

    @Test
    void redirectUrisAreExactMatch() {
        RegisteredClient shell = clientRepository.findByClientId("frontend-shell");
        assertThat(shell.getRedirectUris())
            .containsExactlyInAnyOrder(
                "https://app.saas.example/callback",
                "https://app.saas.example/silent-renew"
            )
            .allSatisfy(uri -> {
                assertThat(uri).doesNotContain("*");
                assertThat(uri).doesNotContain("?");
                assertThat(uri).doesNotEndWith("/");
            });
    }

    @Test
    void clientCredentialsClientHasNoRefreshToken() {
        RegisteredClient payment = clientRepository.findByClientId("payment-service");
        assertThat(payment.getAuthorizationGrantTypes())
            .containsExactly(AuthorizationGrantType.CLIENT_CREDENTIALS);
        assertThat(payment.getAuthorizationGrantTypes())
            .doesNotContain(AuthorizationGrantType.REFRESH_TOKEN);
    }

    @Test
    void confidentialClientHasRotatingRefreshTokens() {
        RegisteredClient shell = clientRepository.findByClientId("frontend-shell");
        assertThat(shell.getTokenSettings().isReuseRefreshTokens()).isFalse();
    }
}

These tests serve as living documentation of security invariants. If someone modifies the client registration to add a wildcard redirect URI or disable PKCE, the tests fail. Security configuration is code. Code should be tested.