A server that starts

Spring Authorization Server on port 9000 with OIDC, a development client and user, token policy, and discovery and JWKS endpoints.

  • Part 2
  • intermediate
  • about 45 minutes

You will build

a Spring Boot authorization server with two filter chains, an in-memory client and user, OIDC enabled, and published signing keys

You will understand

why the protocol endpoints get their own filter chain, what a registered client declares, and what the issuer and JWKS are for

  • one configuration class, in memory

Now we turn the protocol model into a running application.

Our first checkpoint is intentionally simple:

Spring Boot starts on :9000
        +
OAuth authorization endpoints exist
        +
OIDC is enabled
        +
A demo client and user can authenticate
        +
JWTs can be signed

Persistence comes later. Starting in memory makes it easier to understand which Spring components are actually required.

Project baseline

This series uses the repository’s baseline:

Java 25
Spring Boot 4.1.x
Spring Security 7.1.x
Maven
JAR packaging

Generate the project with Spring Initializr and include at least:

  • OAuth2 Authorization Server;
  • Validation;
  • DevTools (optional for local development).

The key dependency is the Spring Boot authorization-server starter. The current Spring Security documentation shows this as the normal Boot entry point for an authorization-server application.

Port and application name

src/main/resources/application.yaml:

spring:
  application:
    name: spring-boot-oauth2-authorization-server
server:
  port: 9000

Why 9000? No protocol requirement. It simply keeps the auth server visually separate from example clients and resource servers.

The first security filter chain

Create AuthorizationServerConfig.

The authorization-server endpoints need their own security matcher and authentication entry point:

@Bean
@Order(1)
SecurityFilterChain authorizationServerSecurityFilterChain(
        HttpSecurity http
) throws Exception {

    http.oauth2AuthorizationServer(authorizationServer -> {
        http.securityMatcher(
                authorizationServer.getEndpointsMatcher()
        );

        authorizationServer.oidc(Customizer.withDefaults());
    });

    http.authorizeHttpRequests(authorize ->
            authorize.anyRequest().authenticated()
    );

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

    return http.build();
}

Then add the ordinary application/login chain:

@Bean
@Order(2)
SecurityFilterChain defaultSecurityFilterChain(
        HttpSecurity http
) throws Exception {

    http.authorizeHttpRequests(authorize ->
            authorize.anyRequest().authenticated()
    );

    http.formLogin(Customizer.withDefaults());

    return http.build();
}

Why two chains?

flowchart TD
    Request[Incoming request]
    M{Authorization Server endpoint?}
    A[Order 1: OAuth/OIDC chain]
    D[Order 2: default application chain]

    Request --> M
    M -->|Yes| A
    M -->|No| D

OAuth protocol endpoints and ordinary application endpoints have different behavior. Separating chains makes later admin/resource-server configuration much easier to reason about.

Development user

For the first checkpoint, an in-memory user is enough:

@Bean
UserDetailsService userDetailsService(
        PasswordEncoder passwordEncoder
) {

    UserDetails user = User.builder()
            .username("user")
            .password(passwordEncoder.encode("password"))
            .roles("USER")
            .build();

    return new InMemoryUserDetailsManager(user);
}

And:

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

Do not interpret these credentials as production defaults. They disappear once we implement database-backed identity and profile-isolated development seed data.

Register a development OAuth client

Create an in-memory registered client:

@Bean
RegisteredClientRepository registeredClientRepository(
        PasswordEncoder passwordEncoder,
        TokenSettings tokenSettings
) {

    RegisteredClient client = RegisteredClient
            .withId(UUID.randomUUID().toString())
            .clientId("demo-client")
            .clientSecret(passwordEncoder.encode("demo-secret"))
            .clientAuthenticationMethod(
                    ClientAuthenticationMethod.CLIENT_SECRET_BASIC
            )
            .authorizationGrantType(
                    AuthorizationGrantType.AUTHORIZATION_CODE
            )
            .authorizationGrantType(
                    AuthorizationGrantType.REFRESH_TOKEN
            )
            .redirectUri("http://127.0.0.1:8081/callback")
            .postLogoutRedirectUri("http://127.0.0.1:8081/")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("read")
            .scope("write")
            .clientSettings(
                    ClientSettings.builder()
                            .requireProofKey(true)
                            .requireAuthorizationConsent(true)
                            .build()
            )
            .tokenSettings(tokenSettings)
            .build();

    return new InMemoryRegisteredClientRepository(client);
}

Important choices:

Authorization Code     yes
Refresh Token          yes
PKCE required          yes
Consent required       yes
Client authentication  client_secret_basic

Token lifetime policy

Keep token policy in a dedicated bean:

@Bean
TokenSettings tokenSettings() {
    return TokenSettings.builder()
            .authorizationCodeTimeToLive(Duration.ofMinutes(5))
            .accessTokenTimeToLive(Duration.ofMinutes(15))
            .refreshTokenTimeToLive(Duration.ofDays(30))
            .reuseRefreshTokens(false)
            .build();
}

reuseRefreshTokens(false) means a refresh operation rotates the refresh token instead of returning the same long-lived value forever.

Issuer

For local development:

@Bean
AuthorizationServerSettings authorizationServerSettings() {
    return AuthorizationServerSettings.builder()
            .issuer("http://localhost:9000")
            .build();
}

Later we will externalize this value. The issuer becomes part of token validation and discovery, so production deployments need a stable public HTTPS issuer.

Generate an RSA JWK

At this stage we can generate an ephemeral RSA key at startup. Later we will replace it with persistent PEM files.

Conceptually:

flowchart LR
    Boot[Application startup] --> RSA[Generate RSA keypair]
    RSA --> JWK[JWKSource]
    JWK --> Sign[JWT encoder signs tokens]
    JWK --> JWKS["/oauth2/jwks"]

A typical configuration is:

@Bean
JWKSource<SecurityContext> jwkSource() {
    RSAKey rsaKey = generateRsaKey();
    JWKSet jwkSet = new JWKSet(rsaKey);
    return new ImmutableJWKSet<>(jwkSet);
}

and:

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

The exact RSA helper is ordinary Java key-generation code. Keep it private to the configuration class for now.

Start the server

./mvnw spring-boot:run

Check discovery:

curl -s \
  http://localhost:9000/.well-known/openid-configuration \
  | jq

You should see endpoint metadata including authorization, token, JWKS, and UserInfo URLs once OIDC is enabled.

Check JWKS:

curl -s http://localhost:9000/oauth2/jwks | jq

Figure: what exists now

flowchart TD
    Browser[Browser] --> Login["/login"]
    Browser --> Authorize["/oauth2/authorize"]
    Authorize --> Client[In-memory RegisteredClient]
    Authorize --> User[In-memory UserDetails]
    Authorize --> Token["/oauth2/token"]
    Token --> JWK[Ephemeral RSA key]
    JWK --> JWT[JWT access token]

Common mistakes

OIDC endpoints missing

Confirm:

authorizationServer.oidc(Customizer.withDefaults());

Without OIDC, openid does not activate the identity layer you expect.

Redirect URI mismatch

OAuth redirect URIs are security-sensitive. Use the exact registered value:

http://127.0.0.1:8081/callback

Do not casually swap it with localhost unless both are registered intentionally.

New signing key after every restart

Expected for now. We deliberately use an ephemeral key at this stage. Persistent keys arrive later.

Verification checklist

[ ] Application starts on 9000
[ ] /login works
[ ] discovery endpoint returns JSON
[ ] /oauth2/jwks returns an RSA public JWK
[ ] demo-client exists in memory
[ ] PKCE is required
[ ] refresh-token reuse is disabled

The code

This part corresponds to these commits in the repository:

  • 7b7fb33 add filter chain for the protocol endpoints
  • 47f77b1 add default security fileer chain
  • afbfcf3 add temp client and user
  • 800f63f feat: configure OAuth2 authorization server

Check out the last one to see the project exactly as this part leaves it:

git checkout 800f63f

References

Next: execute the complete Authorization Code + PKCE flow manually and understand every value involved.