Building an Authorization Server with Spring Boot 4 and Oracle Database

Hi again everyone!

In this post I want to show you how to build a small authorization server using Spring Boot, Spring Security, Spring Authorization Server, and Oracle Database. The idea is simple: we want an application that can expose OAuth2/OIDC authorization-server endpoints, authenticate users whose details are stored in Oracle Database, and provide a small REST API for managing those users.

The complete code for this example is in the azn-server repository. In this article we will build it from scratch and look at the important pieces along the way.

One important note before we start: this version of the example is on the Spring Boot 4.x code line. The repository currently uses Spring Boot 4.0.6, Java 21, Spring Framework 7.0.7, Spring Security 7.0.5, Spring Cloud 2025.1.1, Liquibase 5.0.2 from the Spring Boot BOM, and the Oracle Spring Boot starters.

If you have seen the Spring Boot 3.x version of this sample, the application shape is intentionally the same. The Boot 4 version updates the dependency line and uses the new modular starter names and package names introduced across Spring Boot 4, Spring Framework 7, and Spring Security 7.

What we are building

The application has three main responsibilities:

  • Expose OAuth2 and OpenID Connect endpoints using Spring Authorization Server.
  • Store application users in Oracle Database.
  • Provide a secured user-management API backed by Spring Security method security.

Spring Security gives us a lot here. It gives us the authentication framework, password encoding, UserDetailsService integration, filter chains, method-level authorization, role hierarchy support, and the authorization-server protocol endpoints. Oracle Database gives us a durable user repository, schema ownership, constraints, identity columns, auditing triggers, and a real database engine for integration tests.

This is the application shape:

  • Spring Boot starts the service.
  • Liquibase creates or updates the Oracle schema user.
  • Liquibase creates the USERS table and audit trigger.
  • JPA maps the USERS table into a User entity.
  • Spring Security loads users from that JPA repository.
  • Spring Authorization Server exposes the OAuth2/OIDC endpoints.
  • The REST API lets administrators manage users.

Let’s walk through the build.

Create the Spring Boot project

We start with a normal Spring Boot application. The important thing is to include the dependencies for web endpoints, Spring Authorization Server, actuator, JPA, Liquibase, Oracle UCP, Oracle wallet support, and the test stack.

The parent and version properties select the Spring Boot 4 and Spring Cloud lines:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.6</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>21</java.version>
        <spring-cloud.version>2025.1.1</spring-cloud.version>
        <oracle-spring-boot-starter-version>26.1.1</oracle-spring-boot-starter-version>
    </properties>

Here is the dependency section from pom.xml:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-liquibase</artifactId>
        </dependency>
        <dependency>
            <groupId>com.oracle.database.spring</groupId>
            <artifactId>oracle-spring-boot-starter-ucp</artifactId>
            <version>${oracle-spring-boot-starter-version}</version>
        </dependency>
        <dependency>
            <groupId>com.oracle.database.spring</groupId>
            <artifactId>oracle-spring-boot-starter-wallet</artifactId>
            <version>${oracle-spring-boot-starter-version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <!-- test dependencies -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-authorization-server-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-oracle-free</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

The Spring Cloud BOM is imported separately:

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

There are a couple of things to point out here.

First, spring-boot-starter-security-oauth2-authorization-server brings in the Spring Authorization Server support that provides the OAuth2/OIDC protocol endpoints. That means we do not have to hand-code token endpoints, metadata endpoints, JWK endpoints, or the protocol filter chain.

Spring Boot 4 is more modular than the 3.x line. For this servlet application, the web starter is now spring-boot-starter-webmvc, the test slice starter is spring-boot-starter-webmvc-test, and the authorization-server starter lives under the security naming scheme. The Testcontainers 2 artifacts also use the testcontainers-* artifact names shown above. Letting the Spring Boot parent manage the versions keeps Spring Framework, Spring Security, Liquibase, Jackson, Hibernate, and Testcontainers aligned.

Second, the Oracle UCP starter gives us Oracle Universal Connection Pool integration through Spring Boot configuration. That is useful for real services because the database connection pool is not an afterthought – it is part of the application runtime.

Third, Liquibase owns the schema. Hibernate validates the schema, but Liquibase creates it. That is usually the right split for applications where the database is important enough to be managed deliberately.

Configure Oracle Database and Liquibase

The application uses two database identities:

  • A Liquibase/admin identity that can create and update the application schema.
  • A runtime schema user named USER_REPO that the application uses for normal database access.

Here is the application configuration:

server:
port: 8080
spring:
application:
name: @project.artifactId@
cloud:
# Discovery is opt-in so local runs and tests do not attempt to register.
discovery:
enabled: ${EUREKA_CLIENT_ENABLED:false}
threads:
virtual:
enabled: true
datasource:
# Runtime connections authenticate directly as the application schema user.
url: ${AZN_DATASOURCE_URL:${SPRING_DATASOURCE_URL:}}
username: ${AZN_USER_REPO_USERNAME:USER_REPO}
password: ${AZN_USER_REPO_PASSWORD}
driver-class-name: oracle.jdbc.OracleDriver
type: oracle.ucp.jdbc.PoolDataSource
oracleucp:
connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
connection-pool-name: AznServerConnectionPool
initial-pool-size: 15
min-pool-size: 10
max-pool-size: 30
jpa:
# Keep database access inside service/controller methods, not view rendering.
open-in-view: false
hibernate:
# Liquibase owns schema changes; Hibernate only validates the result.
ddl-auto: validate
properties:
hibernate:
format_sql: true
show-sql: false
liquibase:
# Liquibase uses the admin account directly so it can create USER_REPO.
change-log: classpath:db/changelog/controller.yaml
url: ${AZN_DATASOURCE_URL:${SPRING_DATASOURCE_URL:}}
user: ${AZN_LIQUIBASE_USERNAME:${AZN_DATASOURCE_USERNAME:${SPRING_LIQUIBASE_USER:${SPRING_DATASOURCE_USERNAME:}}}}
password: ${AZN_LIQUIBASE_PASSWORD:${AZN_DATASOURCE_PASSWORD:${SPRING_LIQUIBASE_PASSWORD:${SPRING_DATASOURCE_PASSWORD:}}}}
parameters:
userRepoPassword: ${AZN_USER_REPO_PASSWORD}
enabled: ${RUN_LIQUIBASE:true}
azn:
bootstrap-users:
admin-password: ${ORACTL_ADMIN_PASSWORD:}
user-password: ${ORACTL_USER_PASSWORD:}
management:
endpoint:
health:
show-details: when_authorized
roles: ACTUATOR
endpoints:
web:
exposure:
# Keep actuator surface small; SecurityConfig protects non-health/info endpoints.
include: health,info,prometheus
eureka:
instance:
hostname: ${spring.application.name}
preferIpAddress: true
client:
# Supported for deployments, disabled by default for local/test startup.
service-url:
defaultZone: ${EUREKA_SERVER_ADDRESS:http://localhost:8761/eureka/}
fetch-registry: true
register-with-eureka: true
enabled: ${EUREKA_CLIENT_ENABLED:false}
# Logging
logging:
level:
org.springframework.web: INFO
org.springframework.security: INFO
oracle.obaas.aznserver: INFO

I like this arrangement because the runtime user is not the same as the schema-management user. Liquibase gets the elevated account it needs to create and manage USER_REPO, and the running application connects as USER_REPO. That is a clean security boundary.

Oracle UCP is configured as the datasource type:

    type: oracle.ucp.jdbc.PoolDataSource
    oracleucp:
      connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
      connection-pool-name: AznServerConnectionPool
      initial-pool-size: 15
      min-pool-size: 10
      max-pool-size: 30

That gives us Oracle-aware connection pooling with very little Spring code. We get the operational benefit of a pool that is meant for Oracle Database, while still configuring it in the usual Spring Boot way.

Create the schema with Liquibase

The changelog controller is small:

---
databaseChangeLog:
- include:
file: classpath:db/changelog/dbuser.sql
- include:
file: classpath:db/changelog/table.sql
- include:
file: classpath:db/changelog/trigger.sql

The first changelog creates and maintains the USER_REPO database user:

-- liquibase formatted sql
-- changeset az_admin:initial_user endDelimiter:/ runAlways:true runOnChange:true
DECLARE
l_user VARCHAR2(255);
l_tblspace VARCHAR2(255);
BEGIN
BEGIN
SELECT username INTO l_user FROM DBA_USERS WHERE USERNAME='USER_REPO';
EXCEPTION WHEN no_data_found THEN
EXECUTE IMMEDIATE 'CREATE USER "USER_REPO" IDENTIFIED BY "${userRepoPassword}"';
END;
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" IDENTIFIED BY "${userRepoPassword}" ACCOUNT UNLOCK';
SELECT default_tablespace INTO l_tblspace FROM dba_users WHERE username = 'USER_REPO';
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" QUOTA UNLIMITED ON ' || l_tblspace;
EXECUTE IMMEDIATE 'GRANT CONNECT TO "USER_REPO"';
EXECUTE IMMEDIATE 'GRANT RESOURCE TO "USER_REPO"';
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" DEFAULT ROLE CONNECT,RESOURCE';
END;
/
--rollback drop user "USER_REPO" cascade;

The next changelog creates the user table:

-- liquibase formatted sql
-- changeset az_admin:initial_table
CREATE TABLE USER_REPO.USERS
(
USER_ID NUMBER GENERATED ALWAYS AS IDENTITY (START WITH 1 CACHE 20),
PASSWORD VARCHAR2(255 CHAR) NOT NULL,
ROLES VARCHAR2(255 CHAR) NOT NULL,
USERNAME VARCHAR2(255 CHAR) NOT NULL,
CREATED_ON TIMESTAMP DEFAULT SYSDATE,
CREATED_BY VARCHAR2 (100) DEFAULT COALESCE(
REGEXP_SUBSTR(SYS_CONTEXT('USERENV','CLIENT_IDENTIFIER'),'^[^:]*'),
SYS_CONTEXT('USERENV','SESSION_USER')),
UPDATED_ON TIMESTAMP ,
UPDATED_BY VARCHAR2 (255),
PRIMARY KEY (USER_ID),
CONSTRAINT USERNAME_UQ UNIQUE (USERNAME)
) LOGGING;
COMMENT ON TABLE USER_REPO.USERS is 'Application user repository for OAuth2/OIDC user management';
COMMENT ON COLUMN USER_REPO.USERS.PASSWORD is 'BCrypt hash of the application user password; never store cleartext';
ALTER TABLE USER_REPO.USERS ADD EMAIL VARCHAR2(255 CHAR) NULL;
ALTER TABLE USER_REPO.USERS ADD OTP VARCHAR2(255 CHAR) NULL;
COMMENT ON COLUMN USER_REPO.USERS.OTP is 'BCrypt hash of the one-time password; never store cleartext';
--rollback DROP TABLE USER_REPO.USERS;

There are some good Oracle Database features doing useful work here:

  • GENERATED ALWAYS AS IDENTITY gives us database-managed user ids.
  • The unique constraint protects usernames at the database level.
  • Column comments document sensitive columns right where they live.
  • The table belongs to the USER_REPO schema, not to the application admin user.

Finally, we add a small audit trigger:

-- liquibase formatted sql
-- changeset az_admin:initial_trigger endDelimiter:/
CREATE OR REPLACE EDITIONABLE TRIGGER "USER_REPO"."AUDIT_TRG" BEFORE
UPDATE ON USER_REPO.USERS FOR EACH ROW
BEGIN
:NEW.UPDATED_ON := SYSDATE;
:NEW.UPDATED_BY := COALESCE(REGEXP_SUBSTR(SYS_CONTEXT('USERENV', 'CLIENT_IDENTIFIER'), '^[^:]*'), SYS_CONTEXT('USERENV', 'SESSION_USER'));
END;
/
--rollback DROP TRIGGER "USER_REPO"."AUDIT_TRG";

This is a nice example of letting the database enforce something that belongs in the database. Every update gets audit fields set consistently, whether the update came from this Spring application or from another controlled path later.

Map the Oracle table to a JPA entity

Now we need a JPA entity for the USER_REPO.USERS table.

// Copyright (c) 2023, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Entity
@Table(name = "users", schema = "user_repo")
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = {"password", "otp"})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "USER_ID")
private Long userId;
@Column(name = "USERNAME", nullable = false)
private String username;
/**
* Stores the BCrypt hash that is persisted in USER_REPO.USERS.PASSWORD.
* Cleartext passwords may be accepted at API boundaries, but they must be
* encoded before this entity is saved.
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "PASSWORD", nullable = false, length = 255)
private String password;
@Column(name = "ROLES", nullable = false)
private String roles;
@Column(name = "EMAIL")
private String email;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "OTP")
private String otp;
/**
* Create a user object.
*
* @param username The username.
* @param password The encoded password hash for persistence.
* @param roles The roles assigned the user, as a comma separated list, e.g.
* "ROLE_USER,ROLE_ADMIN".
*/
public User(String username, String password, String roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
// This constructor should only be used during testing with a mock repository,
// when we need to set the id manually
public User(long userId, String username, String password, String roles) {
this(username, password, roles);
this.userId = userId;
}
/**
* Create a user object.
*
* @param username The username.
* @param password The encoded password hash for persistence.
* @param roles The roles assigned the user, as a comma separated list, e.g.
* "ROLE_USER,ROLE_ADMIN".
* @param email The email associated with user account.
*/
public User(String username, String password, String roles, String email) {
this(username, password, roles);
this.email = email;
}
}

There are two small but important security choices in this class.

First, password and otp are write-only for JSON serialization. That means the API can accept these values in request bodies, but it will not serialize them back into responses.

Second, Lombok’s @ToString excludes password and otp. That helps prevent secrets from being accidentally written into logs.

The repository is exactly what we want from Spring Data JPA: small, declarative, and focused on the queries the service needs.

// Copyright (c) 2022, 2023, Oracle and/or its affiliates.
package oracle.obaas.aznserver.repository;
import java.util.List;
import java.util.Optional;
import oracle.obaas.aznserver.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByUsernameIgnoreCase(String username);
Optional<User> findByUserId(Long userId);
List<User> findUsersByUsernameStartsWithIgnoreCase(String username);
Optional<User> findByEmailIgnoreCase(String email);
}

This is one of the places where Spring Data JPA shines. The method names communicate intent, Spring implements the queries, and the application code stays readable.

Adapt the database user to Spring Security

Spring Security authenticates with UserDetails. Our database user is a domain object, so we wrap it in a SecurityUser.

// Copyright (c) 2022, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.model;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (StringUtils.isBlank(user.getRoles())) {
return List.of();
}
return Arrays.stream(user
.getRoles()
.split(","))
.map(SimpleGrantedAuthority::new)
.toList();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

Then we create a UserDetailsService backed by the JPA repository:

// Copyright (c) 2022, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.service;
import oracle.obaas.aznserver.model.SecurityUser;
import oracle.obaas.aznserver.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public JpaUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SecurityUser user = userRepository
.findByUsername(username)
.map(SecurityUser::new)
.orElseThrow(() -> new UsernameNotFoundException("Authentication failed"));
return user;
}
}

This is the bridge between Oracle Database and Spring Security. Once this service exists, Spring Security can authenticate users stored in USER_REPO.USERS.

Configure Spring Security and Spring Authorization Server

The security configuration is the heart of the application. It does several things:

  • Creates a role hierarchy.
  • Enables method security.
  • Creates a dedicated authorization-server filter chain.
  • Creates a separate actuator filter chain.
  • Creates a stateless API filter chain.
  • Provides password encoding.
  • Provides development/test signing keys.
  • Optionally creates a local OAuth client.

Here is the role hierarchy:

    public static final String ROLE_HIERARCHY = "ROLE_ADMIN > ROLE_USERn"
            + "ROLE_ADMIN > ROLE_CONFIG_EDITORn"
            + "ROLE_CONFIG_EDITOR > ROLE_USER";

    /**
     * Configure a role hierarchy such that ADMIN "includes"/implies USER.
     * 
     * @return the hierarchy.
     */

    @Bean
    public RoleHierarchy roleHierarchy() {
        return RoleHierarchyImpl.fromHierarchy(ROLE_HIERARCHY);
    }

    /**
     * Configure method security to use the role hierarchy.
     * 
     * @param roleHierarchy injected by Spring.
     * @return The MethodSecurityExpressionHandler.
     */
    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy);
        return expressionHandler;
    }

Role hierarchy is one of those Spring Security features that is easy to miss but very useful. If an administrator should also be treated as a user, we do not have to duplicate every role check everywhere. We can teach Spring Security that ROLE_ADMIN includes ROLE_USER.

The authorization server gets its own filter chain:

    /**
     * Authorization Server endpoints use their own filter chain so OAuth protocol
     * handling does not inherit API-specific stateless settings.
     *
     * @param http HttpSecurity injected by Spring.
     * @return the SecurityFilterChain.
     * @throws Exception if unable to create the chain.
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        log.debug("In authorizationServerSecurityFilterChain");
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer();

        http
            .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
            .with(authorizationServerConfigurer, authorizationServer ->
                authorizationServer.oidc(Customizer.withDefaults()))
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/.well-known/**", "/oauth2/jwks").permitAll()
                .anyRequest().authenticated())
            .csrf((csrf) -> csrf.ignoringRequestMatchers(authorizationServerConfigurer.getEndpointsMatcher()))
            .exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(
                new LoginUrlAuthenticationEntryPoint("/login"),
                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
        return http.build();
    }

This is where Spring Authorization Server does a lot of heavy lifting. The endpoints matcher identifies the protocol endpoints, OIDC support is enabled, and the well-known metadata and JWK endpoints are allowed anonymously.

In the Spring Boot 4 version, the authorization-server configurer comes from the Spring Security 7 package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization. The older static factory used by the Boot 3 version is gone, so the sample constructs the configurer directly and then applies it to HttpSecurity.

The user-management API has a different shape. It is stateless and uses HTTP Basic:

    /**
     * Create a SecurityFilterChain for the user-management API.
     * @param http HttpSecurity injected by Spring. 
     * @param userDetailsService the JPA-backed user details service.
     * @return the SecurityFilterChain.
     * @throws Exception if unable to create the chain.
     */
    @Bean
    @Order(3)
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, UserDetailsService userDetailsService)
            throws Exception {
        log.debug("In apiSecurityFilterChain");
        http
            .securityMatcher("/user/api/**", "/error/**")
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/error/**").permitAll()
                .requestMatchers("/user/api/v1/ping").permitAll()
                .requestMatchers("/user/api/v1/forgot").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(Customizer.withDefaults())
            .userDetailsService(userDetailsService);
        // The user-management API is stateless and does not use browser sessions or cookies.
        http.csrf(csrf -> csrf.disable());
        return http.build();
    }

The separation between the authorization-server chain and the API chain matters. The OAuth2/OIDC endpoints are protocol endpoints. The user API is a REST API. They have different security needs, so they get different chains.

The authentication provider uses our JPA-backed user details service and a BCrypt password encoder:

    /**
     * Create an Authentication Provider for our UserDetailsService.
     * @param userDetailsService the JPA-backed user details service.
     * @param passwordEncoder password encoder for stored password hashes.
     * @return the AuthenticationProvider.
     */
    @Bean
    public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider auth = new DaoAuthenticationProvider(userDetailsService);
        auth.setPasswordEncoder(passwordEncoder);
        return auth;
    }

And the password encoder is:

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

Passwords in the API enter as clear text at the boundary, but they are stored as BCrypt hashes in Oracle Database. That is exactly the line we want: clear text only at the edge, hashes at rest.

For local development and tests, the application can create an opt-in registered client:

    /**
     * Create an opt-in local client for test and developer-only contexts.
     *
     * Production deployments should configure registered clients explicitly using
     * Spring Boot's authorization-server client properties.
     *
     * @param passwordEncoder password encoder for the client secret.
     * @param clientSecret configured client secret.
     * @return a local RegisteredClientRepository.
     */
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "azn.authorization-server.default-client", name = "enabled",
            havingValue = "true")
    public RegisteredClientRepository localRegisteredClientRepository(PasswordEncoder passwordEncoder,
            @Value("${azn.authorization-server.default-client.secret:}") String clientSecret) {
        if (!StringUtils.hasText(clientSecret)) {
            throw new IllegalStateException("azn.authorization-server.default-client.secret must be set when "
                    + "azn.authorization-server.default-client.enabled=true");
        }
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("azn-local-client")
                .clientSecret(passwordEncoder.encode(clientSecret))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/azn-local-client")
                .scope(OidcScopes.OPENID)
                .scope("user.read")
                .clientSettings(ClientSettings.builder().requireProofKey(true).build())
                .build();
        return new InMemoryRegisteredClientRepository(registeredClient);
    }

Notice that this is opt-in. That is intentional. Local convenience is useful, but production registered clients should be configured deliberately.

The JWK source is also local by default:

    /**
     * Provide process-local signing keys for development and tests.
     *
     * Production deployments should replace this bean with persistent key material
     * so tokens remain verifiable across restarts and rolling deploys.
     *
     * @return the JWK source.
     */
    @Bean
    @ConditionalOnMissingBean
    public JWKSource<SecurityContext> jwkSource() {
        log.warn("Using process-local generated RSA signing keys. Configure a persistent JWKSource bean for "
                + "production so issued tokens remain verifiable across restarts and rolling deploys.");
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

That is fine for development and tests. For production, you would provide persistent signing key material so tokens remain verifiable across restarts and rolling deployments.

Bootstrap the first users

An authorization server needs at least one user to get started. This application creates three bootstrap users on startup by default:

  • obaas-admin
  • obaas-user
  • obaas-config

The initializer is in the main application class:

    @Bean
    @ConditionalOnProperty(prefix = "azn.bootstrap-users", name = "enabled", havingValue = "true",
            matchIfMissing = true)
    ApplicationRunner userStoreInitializer(UserRepository users, PasswordEncoder passwordEncoder,
            @Value("${azn.bootstrap-users.admin-password:}") String adminPassword,
            @Value("${azn.bootstrap-users.user-password:}") String userPassword) {
        return args -> initUserStore(users, passwordEncoder, adminPassword, userPassword);
    }

And the implementation creates missing users with BCrypt-encoded passwords:

    public static void initUserStore(UserRepository users, PasswordEncoder encoder,
            String adminPassword, String userPassword) {
        log.debug("ENTER initUserStore");

        String obaasAdminPwd = adminPassword;
        String obaasUserPwd = userPassword;
        String obaasConfigPwd = obaasUserPwd;

        // Check for obaas-user, if not existent create the user
        if (users.findByUsername(OBAAS_USER).isEmpty()) {
            log.debug("Creating user obaas-user");

            obaasUserPwd = bootstrapPassword("azn.bootstrap-users.user-password", obaasUserPwd);

            users.saveAndFlush(new User(OBAAS_USER, encoder.encode(obaasUserPwd),
                    "ROLE_USER"));
        }

        // Check for obaas-admin, if not existent create the user
        Optional<User> adminUser = users.findByUsername(OBAAS_ADMIN);
        if (adminUser.isEmpty()) {
            log.debug("Creating user obaas-admin");

            obaasAdminPwd = bootstrapPassword("azn.bootstrap-users.admin-password", obaasAdminPwd);

            users.saveAndFlush(new User(OBAAS_ADMIN, encoder.encode(obaasAdminPwd),
                    "ROLE_ADMIN,ROLE_CONFIG_EDITOR,ROLE_USER"));
        }

        // Check for obaas-config, if not existent create the user with the same pwd as
        // obaas-user
        if (users.findByUsernameIgnoreCase(OBAAS_CONFIG).isEmpty()) {
            log.debug("Creating user obaas-config");

            obaasConfigPwd = bootstrapPassword("azn.bootstrap-users.user-password", obaasConfigPwd);

            users.saveAndFlush(new User(OBAAS_CONFIG, encoder.encode(obaasConfigPwd),
                    "ROLE_CONFIG_EDITOR,ROLE_USER"));
        }
    }

The bootstrap passwords come from external configuration. If bootstrap users are enabled and the password properties are missing, startup fails:

    private static String bootstrapPassword(String propertyName, String configuredPassword) {
        if (StringUtils.isNotBlank(configuredPassword)) {
            return configuredPassword;
        }
        throw new IllegalStateException(propertyName + " must be set when azn.bootstrap-users.enabled=true");
    }

That is much better than quietly creating default passwords.

Build the user-management API

The API is a normal Spring REST controller:

@RestController
@RequestMapping("/user/api/v1")
@Slf4j
public class DbUserRepoController {
public static final String ROLE_ADMIN = "ADMIN";
private static final String isAdminUser = "hasRole('ADMIN')";
private static final String isUser = "hasRole('USER')";
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("^(?=.*[?!$%^*\-_])(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{12,}$");
final UserRepository userRepository;
final PasswordEncoder passwordEncoder;
public DbUserRepoController(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}

The connect endpoint is a simple authenticated check:

    @PreAuthorize("hasAnyRole('ADMIN','USER','CONFIG_EDITOR')")
    @GetMapping("/connect")
    public ResponseEntity<String> connect() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String authorities = userDetails.getAuthorities().toString();

        log.debug("/connect Username: {}", authentication.getName());
        log.debug("/connect Authorities: {}", userDetails.getAuthorities());
        log.debug("/connect Details: {}", authentication.getDetails());

        return new ResponseEntity<>(authorities, HttpStatus.OK);
    }

Creating a user is restricted to admins:

    @PreAuthorize(isAdminUser)
    @PostMapping("/createUser")
    public ResponseEntity<?> createUser(@RequestBody User user) {

        // If user exists return HTTP Status 409.
        Optional<User> checkUser = userRepository.findByUsernameIgnoreCase(user.getUsername());
        if (checkUser.isPresent()) {
            log.debug("User exists");
            return new ResponseEntity<>("User already exists", HttpStatus.CONFLICT);
        }

        if (!isValidPassword(user.getPassword())) {
            return new ResponseEntity<>("Password does not meet complexity requirements",
                    HttpStatus.UNPROCESSABLE_ENTITY);
        }

        if (StringUtils.isNotEmpty(user.getEmail())) {
            Optional<User> userAlreadyAssociatedWithEMail = userRepository.findByEmailIgnoreCase(user.getEmail());
            if (userAlreadyAssociatedWithEMail.isPresent()) {
                log.debug("User exists");
                return new ResponseEntity<>("Another user exists with same email", HttpStatus.CONFLICT);
            }
        }

        // Validate roles in RequestBody
        boolean hasValidRole = validateRole(user);
        log.debug("Valid role: {}", hasValidRole);

        // If Valid role create the user else send HTTP 422
        if (hasValidRole) {
            try {
                User users = userRepository.save(new User(
                        user.getUsername(),
                        passwordEncoder.encode(user.getPassword()),
                        user.getRoles(), user.getEmail()));
                return new ResponseEntity<>(users, HttpStatus.CREATED);
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }
        } else {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }
    }

There are a few important details in here:

  • Usernames are checked case-insensitively.
  • Duplicate emails are rejected.
  • Password complexity is enforced before saving.
  • Roles are validated against the enum.
  • Passwords are encoded before the entity is persisted.

The use of ResponseEntity.status(...).build() is intentional in the Boot 4 version. Spring Framework 7 adds ResponseEntity constructors that make new ResponseEntity<>(null, status) ambiguous, so empty responses should use the builder API.

The password validation is intentionally small and explicit:

    private boolean isValidPassword(String password) {
        return StringUtils.isNotBlank(password) && PASSWORD_PATTERN.matcher(password).matches();
    }

The role validation uses the enum:

    private boolean validateRole(User user) {
        try {
            if (StringUtils.isBlank(user.getRoles())) {
                return false;
            }
            Arrays.stream(user.getRoles().toUpperCase()
                    .replace("[", "")
                    .replace("]", "")
                    .replace(" ", "")
                    .split(","))
                    .map(UserRoles::valueOf)
                    .toList();
            return true;
        } catch (IllegalArgumentException illegalArgumentException) {
            return false;
        }
    }

Password changes are available to admins or to the user changing their own password:

    @PreAuthorize(isUser)
    @PutMapping("/updatePassword")
    public ResponseEntity<User> changePassword(@RequestBody UserInfoDto userInfo) {

        if (!isValidPassword(userInfo.password())) {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }

        // Check if the user is a user with ADMIN
        SecurityContext securityContext = SecurityContextHolder.getContext();
        boolean isAdminUser = false;

        for (GrantedAuthority role : securityContext.getAuthentication().getAuthorities()) {
            if (role.getAuthority().contains(ROLE_ADMIN)) {
                isAdminUser = true;
            }
        }

        // TODO: Must update the correspondent secret??

        // If the username of the authenticated user matches the requestbody username,
        // or if it is a user with ROLE_ADMIN
        if ((userInfo.username().compareTo(securityContext.getAuthentication().getName()) == 0) || isAdminUser) {
            try {
                Optional<User> user = userRepository.findByUsername(userInfo.username());
                if (user.isPresent()) {
                    user.get().setPassword(passwordEncoder.encode(userInfo.password()));
                    userRepository.saveAndFlush(user.get());
                    return ResponseEntity.status(HttpStatus.OK).build();
                } else {
                    return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
                }
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }
        } else {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
    }

And the forgot-password flow keeps OTP values hashed too:

    @PostMapping("/forgot")
    public ResponseEntity<UserInfoDto> createOTP(@RequestBody(required = true) User inUser) {
        if (StringUtils.isNotEmpty(inUser.getUsername()) && StringUtils.isNotEmpty(inUser.getOtp())) {
            try {
                Optional<User> user = userRepository.findByUsernameIgnoreCase(inUser.getUsername());
                if (user.isEmpty()) {
                    return new ResponseEntity<>(HttpStatus.NO_CONTENT);
                }
                user.get().setOtp(passwordEncoder.encode(inUser.getOtp()));
                userRepository.saveAndFlush(user.get());
                return ResponseEntity.status(HttpStatus.OK).build();
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }

        } else {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }
    }

The reset endpoint compares the provided OTP against the stored BCrypt hash:

    @PutMapping("/forgot")
    public ResponseEntity<?> reset(@RequestBody(required = true) User inUser) {
        if (StringUtils.isNotEmpty(inUser.getUsername()) && StringUtils.isNotEmpty(inUser.getOtp())
                && StringUtils.isNotEmpty(inUser.getPassword())) {
            if (!isValidPassword(inUser.getPassword())) {
                return new ResponseEntity<>("Password does not meet complexity requirements",
                        HttpStatus.UNPROCESSABLE_ENTITY);
            }
            try {
                Optional<User> user = userRepository.findByUsernameIgnoreCase(inUser.getUsername());
                if (user.isEmpty()) {
                    return new ResponseEntity<>("User does not exist", HttpStatus.NO_CONTENT);
                }

                if (StringUtils.isEmpty(user.get().getOtp())) {
                    return new ResponseEntity<>("OTP not  generated.", HttpStatus.CONFLICT);
                }

                if (StringUtils.isEmpty(user.get().getPassword())) {
                    return new ResponseEntity<>("Password not  provided.", HttpStatus.CONFLICT);
                }

                if (!passwordEncoder.matches(inUser.getOtp(), user.get().getOtp())) {
                    return new ResponseEntity<>("OTP does not match.", HttpStatus.CONFLICT);
                }

                if (passwordEncoder.matches(inUser.getPassword(), user.get().getPassword())) {
                    return new ResponseEntity<>("Password can not be same as previous.", HttpStatus.CONFLICT);
                }

                user.get().setOtp(null);
                user.get().setPassword(passwordEncoder.encode(inUser.getPassword()));
                userRepository.saveAndFlush(user.get());

                return new ResponseEntity<>("Password successfully changed.",
                        HttpStatus.OK);
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }

        } else {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }
    }

Again, the pattern is the same: accept secret values at the boundary, compare or encode them through Spring Security’s PasswordEncoder, and do not disclose them in responses.

Run Locally

Now let’s run the finished application. This section follows the Run Locally flow from the repository README, because it is the quickest way to prove that Oracle Database is available, Liquibase can create the schema, and the bootstrap users can be created.

Start with an Oracle database that the Liquibase admin user can connect to. For a disposable local Oracle database, you can use the same image family as the integration tests:

docker run --name azn-oracle --rm -p 1521:1521
-e ORACLE_PASSWORD='LocalSystem123!'
gvenzl/oracle-free:23.26.1-slim-faststart

In another terminal, configure the app. The USER_REPO password is the Oracle schema password that Liquibase assigns to the runtime database user. The bootstrap passwords are application user passwords and will be stored as BCrypt hashes in USER_REPO.USERS.

export AZN_DATASOURCE_URL='jdbc:oracle:thin:@//localhost:1521/FREEPDB1'
export AZN_LIQUIBASE_USERNAME='SYSTEM'
export AZN_LIQUIBASE_PASSWORD='LocalSystem123!'
export AZN_USER_REPO_USERNAME='USER_REPO'
export AZN_USER_REPO_PASSWORD='LocalUserRepo123!'
export ORACTL_ADMIN_PASSWORD='LocalAdmin123!'
export ORACTL_USER_PASSWORD='LocalUser123!'
export AZN_AUTHORIZATION_SERVER_DEFAULT_CLIENT_ENABLED=true
export AZN_AUTHORIZATION_SERVER_DEFAULT_CLIENT_SECRET='LocalClient123!'

Run the application:

mvn spring-boot:run

The app listens on http://localhost:8080.

Smoke Test API

Now we can walk through the finished code using the Smoke Test API flow from the README. These calls verify that the app is running, the Authorization Server endpoints are exposed, users can authenticate, and an admin can create and update users.

Before we call the API, set up the shell variables used by the walkthrough:

export BASE_URL='http://localhost:8080'
export ADMIN_USER='obaas-admin'
export ADMIN_PASSWORD='LocalAdmin123!'
export TEST_USER='readme-user'
export TEST_PASSWORD='ReadmeUser123!'
export TEST_PASSWORD_2='ReadmeUser456!'
export TEST_EMAIL='readme-user@example.com'

First, check the anonymous endpoints:

curl -i "$BASE_URL/actuator/health"
curl -i "$BASE_URL/user/api/v1/ping"
curl -i "$BASE_URL/.well-known/oauth-authorization-server"
curl -i "$BASE_URL/oauth2/jwks"

This verifies the basic shape of the running service. Actuator health is available, the unauthenticated ping endpoint works, the authorization server metadata is published, and the JWK endpoint is available for token verification.

Now authenticate with the bootstrap admin user:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD" "$BASE_URL/user/api/v1/connect"
curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD" "$BASE_URL/user/api/v1/pingadmin"

The admin user was inserted during startup by the bootstrap initializer. The password came from configuration and was stored in Oracle Database as a BCrypt hash.

If you are rerunning this sequence, remove the sample user first:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X DELETE
"$BASE_URL/user/api/v1/deleteUsername?username=$TEST_USER"

Create a user. Passwords must be at least 12 characters and include uppercase, lowercase, a number, and one of ?!$%^*-_.

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","password":"$TEST_PASSWORD","roles":"ROLE_USER","email":"$TEST_EMAIL"}"
"$BASE_URL/user/api/v1/createUser"

This call exercises several things at once. Spring Security authorizes the admin request, the controller validates the role and password, the password is encoded with BCrypt, and JPA stores the user in USER_REPO.USERS.

Verify that the new user can authenticate and use a user endpoint:

curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/connect"
curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/pinguser"

Now find the user as an admin. Password and OTP fields are write-only and should not appear in the response body.

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
"$BASE_URL/user/api/v1/findUser?username=$TEST_USER"

That response is a useful security check. The API can accept sensitive values, but it should not echo them back.

Next, change the user’s role, then authenticate with the same user against the config-editor endpoint:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","roles":"ROLE_CONFIG_EDITOR"}"
"$BASE_URL/user/api/v1/changeRole"
curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/pingceditor"

That shows the method-security path working. The role stored in Oracle Database changes, Spring Security reads it through the JPA-backed UserDetailsService, and the endpoint authorization follows the updated authorities.

Change the user’s email:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","email":"updated-$TEST_EMAIL"}"
"$BASE_URL/user/api/v1/changeEmail"

Change the user’s password as the user, then authenticate with the new password:

curl -i -u "$TEST_USER:$TEST_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","password":"$TEST_PASSWORD_2"}"
"$BASE_URL/user/api/v1/updatePassword"
curl -i -u "$TEST_USER:$TEST_PASSWORD_2" "$BASE_URL/user/api/v1/connect"

Again, the cleartext password only crosses the API boundary. The value stored in Oracle Database is a BCrypt hash.

Exercise the forgot-password flow. The OTP is accepted in the request but is stored as a BCrypt hash and is not disclosed by the lookup endpoint.

curl -i
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","otp":"123456"}"
"$BASE_URL/user/api/v1/forgot"
curl -i "$BASE_URL/user/api/v1/forgot?username=$TEST_USER"
curl -i
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","otp":"123456","password":"ReadmeReset123!"}"
"$BASE_URL/user/api/v1/forgot"

Finally, verify the opt-in local OAuth client with the client credentials flow:

curl -i -u 'azn-local-client:LocalClient123!'
-d 'grant_type=client_credentials'
-d 'scope=user.read'
"$BASE_URL/oauth2/token"

That call hits the Spring Authorization Server token endpoint and should return a bearer access token.

When you are finished, you can clean up the demo user:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X DELETE
"$BASE_URL/user/api/v1/deleteUsername?username=$TEST_USER"

The most useful local endpoints are:

  • GET /actuator/health
  • GET /.well-known/oauth-authorization-server
  • GET /oauth2/jwks
  • GET /user/api/v1/ping

At this point we have an Oracle-backed user repository, Spring Security authentication against that repository, a working user-management API, and Spring Authorization Server issuing tokens.

Test against a real Oracle Database

The integration tests use Testcontainers with Oracle Free:

@Testcontainers
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
abstract class OracleIntegrationTestSupport {
private static final DockerImageName ORACLE_IMAGE =
DockerImageName.parse("gvenzl/oracle-free:23.26.1-slim-faststart");
private static final AtomicInteger POOL_SEQUENCE = new AtomicInteger();
private static final SecureRandom PASSWORD_RANDOM = new SecureRandom();
static final String BOOTSTRAP_PASSWORD = generatedPassword();
static final String USER_REPO_PASSWORD = generatedPassword();
private static final String ORACLE_PASSWORD = generatedPassword();
@Container
static final OracleContainer ORACLE = new OracleContainer(ORACLE_IMAGE)
.withPassword(ORACLE_PASSWORD);

The test support wires Spring Boot to the container:

    static void configureOracleProperties(DynamicPropertyRegistry registry) {
        String poolName = "AznServerOracleIT-" + POOL_SEQUENCE.incrementAndGet();

        registry.add("spring.datasource.url", ORACLE::getJdbcUrl);
        registry.add("spring.datasource.username", () -> "USER_REPO");
        registry.add("spring.datasource.password", () -> USER_REPO_PASSWORD);
        registry.add("spring.datasource.driver-class-name", ORACLE::getDriverClassName);
        registry.add("spring.datasource.type", () -> "oracle.ucp.jdbc.PoolDataSource");
        registry.add("spring.datasource.oracleucp.connection-factory-class-name",
                () -> "oracle.jdbc.pool.OracleDataSource");
        registry.add("spring.datasource.oracleucp.connection-pool-name", () -> poolName);
        registry.add("spring.datasource.oracleucp.initial-pool-size", () -> "1");
        registry.add("spring.datasource.oracleucp.min-pool-size", () -> "1");
        registry.add("spring.datasource.oracleucp.max-pool-size", () -> "4");
        registry.add("spring.liquibase.url", ORACLE::getJdbcUrl);
        registry.add("spring.liquibase.user", () -> "system");
        registry.add("spring.liquibase.password", ORACLE::getPassword);
        registry.add("spring.liquibase.parameters.userRepoPassword", () -> USER_REPO_PASSWORD);
        registry.add("spring.liquibase.enabled", () -> "true");
        registry.add("azn.bootstrap-users.enabled", () -> "true");
        registry.add("azn.bootstrap-users.admin-password", () -> BOOTSTRAP_PASSWORD);
        registry.add("azn.bootstrap-users.user-password", () -> BOOTSTRAP_PASSWORD);
        registry.add("azn.authorization-server.default-client.secret", () -> "TestLocalClientSecret123!");
        registry.add("eureka.client.enabled", () -> "false");
        registry.add("spring.cloud.discovery.enabled", () -> "false");
        registry.add("spring.cloud.service-registry.auto-registration.enabled", () -> "false");
    }

This is a big benefit of the Oracle Testcontainers support. The tests exercise the actual database behavior: Liquibase, schema creation, identity columns, BCrypt hashes stored in the table, and the Spring Boot datasource configuration.

The authorization server integration test verifies metadata, JWKs, and token issuance:

    @Test
    void exposesAuthorizationServerMetadataAndJwks() {
        ResponseEntity<String> metadata = restTemplate.getForEntity(
                url("/.well-known/oauth-authorization-server"), String.class);
        ResponseEntity<String> jwks = restTemplate.getForEntity(url("/oauth2/jwks"), String.class);

        assertThat(metadata.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(metadata.getBody()).contains("authorization_endpoint", "token_endpoint", "jwks_uri");
        assertThat(jwks.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(jwks.getBody()).contains(""keys"");
    }

    @Test
    void issuesClientCredentialsAccessToken() {
        HttpHeaders headers = new HttpHeaders();
        headers.setBasicAuth("integration-client", "integration-secret");
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "client_credentials");
        body.add("scope", "user.read");

        ResponseEntity<String> response = restTemplate.postForEntity(url("/oauth2/token"),
                new HttpEntity<>(body, headers), String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).contains("access_token", "Bearer");
    }

And the user API integration test verifies that secrets are not leaked:

    @Test
    void adminCanCreateAndFindUserWithoutLeakingSecrets() {
        TestRestTemplate admin = restTemplate.withBasicAuth("obaas-admin", BOOTSTRAP_PASSWORD);
        Map<String, String> request = Map.of(
                "username", "api-user",
                "password", "StrongPass123!",
                "roles", "ROLE_USER",
                "email", "api-user@example.com");

        ResponseEntity<String> createResponse = admin.postForEntity(url("/user/api/v1/createUser"),
                request, String.class);
        ResponseEntity<String> findResponse = admin.getForEntity(url("/user/api/v1/findUser?username=api-user"),
                String.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createResponse.getBody()).contains("api-user").doesNotContain("StrongPass123!");
        assertThat(findResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(findResponse.getBody())
                .contains("api-user")
                .doesNotContain("StrongPass123!")
                .doesNotContain("otp");
        assertThat(userRepository.findByUsername("api-user"))
                .hasValueSatisfying(user -> {
                    assertThat(user.getPassword()).isNotEqualTo("StrongPass123!").startsWith("$2");
                    assertThat(passwordEncoder.matches("StrongPass123!", user.getPassword())).isTrue();
                });
    }

That is exactly the sort of test I like for this kind of application. It verifies behavior from the outside, and then checks the database-backed repository to confirm the security property we care about: the cleartext password was not stored.

Wrap up

We now have a working Spring Boot 4 authorization server backed by Oracle Database.

Spring Security and Spring Authorization Server give us the authentication framework, filter chains, method-level authorization, password encoding, OAuth2/OIDC endpoints, JWK support, and token issuance. Oracle Database gives us a proper persistent user repository with schema ownership, constraints, audit fields, identity columns, and real integration testing through Testcontainers.

There are a few production topics that deserve their own treatment, especially persistent signing keys, production registered-client storage, wallet-based database connectivity, deployment configuration, and observability. But the core pattern is here: let Spring Security handle security, let Oracle Database handle the durable user store, and keep the boundary between them small and explicit.

The important Spring Boot 4 changes in this version are mostly at the edges: updated starter names, Spring Security 7 package/API moves, Spring Framework 7 response builder usage for empty responses, and Testcontainers 2 artifact names. The application design stays pleasantly boring, which is exactly what I want from a framework upgrade.

About Mark Nelson

Mark Nelson is a Developer Evangelist at Oracle, focusing on microservices and AI. Mark has served as a Section Leader in Stanford's Code in Place program that has introduced tens of thousands of people to the joy of programming, he is a published author, a reviewer and contributor, a content creator and a lifelong learner. He enjoys traveling, meeting people and learning about foods and cultures of the world. Mark has worked at Oracle since 2006 and before that at IBM since 1994.
This entry was posted in Uncategorized and tagged , , , , , , , . Bookmark the permalink.

Leave a Reply