Add real business actions to your Spring AI app with Oracle (part 3 of 4)

By the end of Episode 2, the assistant could answer policy questions from Oracle Vector Store and remember conversations across requests. That combination already felt more like a real application than a bare LLM wrapper.

But it still could not act on anything.

Ask it about order ORD-1002 and it would explain the shipping policy. It could not look up the actual order. Ask it to return a product and it would describe the return process. It could not initiate the return. The knowledge was there. The memory was there. The connection to the backend was not.

Episode 3 adds that. Watch it here.

The distinction that matters

Before looking at the code, it is worth being precise about what tool calling is and what it is not.

The model does not get direct access to the database. It does not write SQL. It does not update rows. What it gets is the ability to request that the backend perform a specific named action. The backend then decides whether to allow it, validates the input against business rules, performs the operation if allowed, and returns a result. The model reads that result and generates a response.

This is the difference between AI orchestration and AI autonomy. The model orchestrates. The backend decides. That distinction is what makes the system trustworthy rather than unpredictable.

What changes

The architecture from Episode 2 carries forward unchanged. The memory advisor, the vector-store advisor, and the Oracle-backed persistence are all still there. The only additions are one tool class, one new entity, and one line in ChatClientConfig.

That line:

return chatClientBuilder
.defaultSystem(assistantProperties.systemPrompt())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
questionAnswerAdvisor
)
.defaultTools(agentTools)
.build();

defaultTools(agentTools) registers the AgentTools bean with the chat client. From that point, the model knows those tools exist and can request them during any conversation.

The system prompt was also updated to tell the model how to use the tools and to reinforce that tool results are authoritative:

app:
assistant:
system-prompt: >
You are ShopAssist, a concise and practical support assistant for a demo
electronics store. Use retrieved policy context when it is available.
Use tools for order status lookup, return initiation, and support ticket
creation. Treat tool results as the source of truth for business actions
and explain validation failures clearly. Do not invent order details.
Do not invent action outcomes.

The tool class

AgentTools.java contains three methods, each annotated with @Tool. That annotation is what exposes the method to the model. Spring AI reads the description and parameter annotations at startup and includes them in the model’s context so it knows what each tool does and when to use it.

@Tool(description = "Look up the current status and basic details for a ShopAssist order.")
@Transactional(readOnly = true)
public String lookupOrderStatus(
@ToolParam(description = "The ShopAssist order ID, for example ORD-1002.") String orderId
) { ... }
@Tool(description = "Initiate a return for an eligible delivered ShopAssist order after backend validation.")
@Transactional
public String initiateReturn(
@ToolParam(description = "The ShopAssist order ID, for example ORD-1001.") String orderId,
@ToolParam(description = "The customer's reason for the return.") String reason
) { ... }
@Tool(description = "Create a support ticket for an existing ShopAssist order.")
@Transactional
public String createSupportTicket(
@ToolParam(description = "The ShopAssist order ID, for example ORD-1002.") String orderId,
@ToolParam(description = "A short description of the customer issue.") String issue,
@ToolParam(description = "Ticket priority such as LOW, NORMAL, HIGH, or URGENT.", required = false) String priority
) { ... }

The return types are all String. That is intentional — plain text results are easy for the model to summarise naturally without needing to parse structured data.

lookupOrderStatus is marked @Transactional(readOnly = true). initiateReturn and createSupportTicket are @Transactional because they write to Oracle. The transaction boundary sits inside the tool method, so a validation failure exits cleanly without any partial database change.

How a tool call actually flows

When the model decides a tool is needed, it does not generate a response immediately. Instead it emits a structured tool-call request back to the application. Spring AI intercepts that, invokes the matching method, and sends the result back to the model as a tool message. The model then generates its final response with the result in context.

The full round trip for a return request looks like this: the user message arrives, the memory advisor loads conversation history, the vector-store advisor retrieves relevant policy documents, the model sees all of that and decides to call initiateReturn, the backend validates and either updates Oracle or returns a failure reason, and the model then writes the response to the user based on what the backend actually said.

Business rules in the backend

The return validation is worth looking at directly because it is the clearest example of the backend owning business policy:

private String initiateReturnForOrder(CustomerOrder order) {
if (order.getStatus() == OrderStatus.PREPARING_RETURN) {
return "A return has already been initiated for order %s."
.formatted(order.getOrderId());
}
if (order.getStatus() != OrderStatus.DELIVERED) {
return "Order %s is currently %s and is not eligible for return until it has been delivered."
.formatted(order.getOrderId(), order.getStatus());
}
if (daysSincePurchase(order) > RETURN_WINDOW_DAYS) {
return "Order %s is outside the %d-day return window."
.formatted(order.getOrderId(), RETURN_WINDOW_DAYS);
}
order.markPreparingReturn();
customerOrderRepository.save(order);
return "Return initiated for order %s. The order status is now %s."
.formatted(order.getOrderId(), order.getStatus());
}

Four checks in sequence: already returning, not yet delivered, outside the 30-day window, then success. Every failure exits early with a plain-text reason and touches nothing in the database. Only the last path calls customerOrderRepository.save().

The markPreparingReturn() call is a domain method on CustomerOrder. The status change lives on the entity, not scattered across service or tool layers.

The support ticket entity

SupportTicket is a simple JPA entity backed by the SUPPORT_TICKET table. Episode 3 adds it with Hibernate’s ddl-auto=update creating the table on first startup — no migration script.

@Entity
@Table(name = "SUPPORT_TICKET")
public class SupportTicket {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "support_ticket_seq")
@SequenceGenerator(name = "support_ticket_seq", sequenceName = "SUPPORT_TICKET_SEQ", allocationSize = 1)
@Column(name = "TICKET_ID", nullable = false, updatable = false)
private Long ticketId;
@Column(name = "ORDER_ID", nullable = false, length = 40)
private String orderId;
@Column(name = "ISSUE", nullable = false, length = 1000)
private String issue;
@Column(name = "PRIORITY", nullable = false, length = 20)
private String priority;
@Column(name = "STATUS", nullable = false, length = 20)
private String status;
@Column(name = "CREATED_AT", nullable = false)
private LocalDateTime createdAt;
...
}

The key ID uses GenerationType.SEQUENCE with an explicit @SequenceGenerator, which is the recommended Oracle pattern. Priority and status are plain strings rather than enums — the tool normalises whatever the model passes and defaults anything unrecognised to NORMAL.

Trying it

The application seeds four deterministic demo orders on startup: ORD-1001 (delivered, within the 30-day return window), ORD-1002 (shipped), ORD-1003 (delivered, outside the return window), and ORD-1004 (processing).

Order lookup:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"What is the status of order ORD-1002?"}' | jq

The assistant calls lookupOrderStatus, reads the live status from Oracle, and returns it. It does not guess or interpolate from the conversation.

Successful return:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"I want to return order ORD-1001 because the product was defective."}' | jq

The backend finds the order, confirms it is delivered and within the return window, calls markPreparingReturn(), and saves to Oracle. The status in CUSTOMER_ORDER is now PREPARING_RETURN.

Failed return:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"I want to return order ORD-1003 because the product was defective."}' | jq

ORD-1003 was delivered 45 days ago. The backend rejects it at the return-window check and returns a clear reason. Nothing is written to the database. The model relays the rejection to the user.

This failure case is as important as the success case. It demonstrates that the model cannot bypass business rules by asking politely.

Support ticket:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"Create a high-priority support ticket for ORD-1002 because shipping is stuck."}' | jq

The backend verifies the order exists, normalises the priority to HIGH, sets status to OPEN, and inserts a row into SUPPORT_TICKET.

Inspecting Oracle directly

After those four requests, you can see the backend state directly:

SELECT ORDER_ID, STATUS FROM CUSTOMER_ORDER;

ORD-1001 now shows PREPARING_RETURN. The other three are unchanged.

SELECT TICKET_ID, ORDER_ID, PRIORITY, STATUS FROM SUPPORT_TICKET;

One row: the high-priority ticket for ORD-1002.

The assistant is useful because it is connected to real application state. Not because the model became more capable.

Oracle is now storing four kinds of data

Episode 1 added relational order data and vector embeddings. Episode 2 added conversational state. Episode 3 adds support ticket rows written by tool calls.

All four live in the same Oracle database: CUSTOMER_ORDER, the vector store table, SPRING_AI_CHAT_MEMORY, and SUPPORT_TICKET. Still one database connection from the application. No additional infrastructure.

What is next

The application can now retrieve knowledge, remember conversations, and perform backend actions. But everything still happens synchronously inside a single HTTP request. The model calls a tool, waits for the result, and then responds.

That works for simple actions. It does not work for anything that takes time, depends on external systems, or needs to be retried. Episode 4 starts moving toward event-driven processing — work that can happen outside the request cycle and feed back into the conversation when it completes.

Repo: https://github.com/markxnelson/shopassist/tree/EP3

Posted in Uncategorized | Tagged , , , , , | Leave a comment

From Oracle Database to Grafana: What Oracle Database Metrics Exporter Does for Developers

Freshness note: At the time of writing, the current exporter release is 2.3.1, the default listen address is :9161, the default telemetry path is /metrics, and container examples use container-registry.oracle.com/database/observability-exporter:2.3.1. Treat these as version-specific details and recheck current documentation before relying on copy/paste commands.

Application telemetry stops too early

A Spring Boot AI service can look healthy in the places developers check first. The HTTP layer reports normal error rates. Application metrics show request latency, JVM behavior, token timing, and maybe HikariCP connection pool usage. Then a retrieval-augmented generation, or RAG, endpoint slows down, and the only obvious clue is that “the database call took longer.”

That clue is too thin when Oracle Database is part of the user experience. The database may hold vectors, JSON documents, relational records, queues, conversation state, ingestion metadata, and application state. A slow answer might come from a code change in the service, a different retrieval query, an ingestion job competing with search traffic, a session spike, a wait-class shift, or a SQL hotspot.

Application telemetry is necessary, but it often stops at the application boundary. Database-side metrics fill a different gap: they show what the database was doing during the same time window as application latency, pool pressure, model calls, deployments, and workload changes.

Oracle Database Metrics Exporter exposes Oracle Database metrics so observability tools can scrape, store, visualize, and alert on database behavior alongside application behavior. The payoff is not another dashboard for its own sake. The payoff is correlation by workflow: when a RAG endpoint slows down, developers and platform teams can inspect database-side signals in the same timeline as the service that depends on them.

What a database exporter does

A database exporter is a small service that connects to a database, collects selected metrics, and exposes those metrics through an endpoint that monitoring systems can scrape.

That definition matters because an exporter is a bridge, not the destination. It is not the database. It is not Grafana. It is not an application metrics library. It is not a trace collector. Its job is to turn database-side signals into input for the observability pipeline you already use.

In a Prometheus-style setup, the monitoring system usually follows a pull model. Prometheus or another compatible scraper periodically requests metrics from a target endpoint. The exporter is that target. It connects to Oracle Database, collects configured metrics, and exposes them over HTTP in a scrapeable format.

That changes the debugging conversation. Instead of asking for a separate database view after the fact, the application team can start with a shared timeline: request latency rose at 10:05, connection pool wait time rose at 10:06, sessions increased at 10:06, and database wait behavior changed at 10:07. Metrics do not finish the diagnosis, but they tell everyone where to look next.

What Oracle Database Metrics Exporter is

Tthe exporter is a bridge between Oracle Database instances and observability tools. The exporter exposes metrics in a format compatible with Prometheus-style scraping. metrics are OpenTelemetry and Prometheus-compatible. This article focuses on the Prometheus path because it is the most direct path to reason about: the exporter exposes metrics over HTTP, Prometheus or a compatible scraper collects and stores them, and Grafana visualizes them through a Prometheus-compatible data source.

In an OpenTelemetry-oriented platform, a common pattern is to use the OpenTelemetry Collector as part of the metrics pipeline, for example by scraping the exporter’s Prometheus-format endpoint with the Collector’s Prometheus receiver and forwarding metrics to a backend. That does not mean the exporter emits traces, performs automatic application-to-database trace correlation, or natively pushes OTLP metrics to every backend.

The exporter project is free and open source. It is available under your choice of the Universal Permissive License v1.0, or the MIT license . That statement applies to the exporter project itself; database, infrastructure, cloud service, managed backend, and SaaS observability costs are separate.

The exporter works across on-premises, cloud, Kubernetes, single-instance, clustered, and Autonomous Database deployments. Check the current docs for exact version, network, authentication, wallet, and privilege requirements in your environment.

The exporter can provide default metrics and custom SQL-based metrics defined in YAML or TOML. Custom metrics are powerful, but they need design review. Each query needs appropriate database grants and should be evaluated for execution cost, scrape interval, timeout behavior, label cardinality, and sensitive data exposure.

The path from Oracle Database to Grafana

Grafana is not collecting from Oracle Database directly in this path. The usual flow looks like this:

The default endpoint details are version-specific. In version 2.3.1, the exporter defaults to --web.listen-address=:9161 and --web.telemetry-path="/metrics".

The networking detail that trips up many first demos is point of view. Use the address reachable from Prometheus, not necessarily the address reachable from your terminal. For a same-host local test, this might be localhost:9161. In Docker Compose, localhost usually means the Prometheus container itself, so the target is usually the exporter service name. In Kubernetes, it would normally be a Service DNS name.

For a same-host local test where Prometheus can reach the exporter on localhost, the scrape target might look like this:

scrape_configs:
- job_name: "oracle-database"
static_configs:
- targets: ["localhost:9161"]

For a Docker Compose-style setup where exporter is the service name reachable from the Prometheus container, the target would look more like this:

scrape_configs:
- job_name: "oracle-database"
static_configs:
- targets: ["exporter:9161"]

These snippets show the shape of the scrape configuration, not a complete production Prometheus setup. The correct target depends on where Prometheus runs, how the exporter is exposed, and what network rules sit between the scraper and the exporter.

A first useful local check

The smallest useful demo is not a full dashboard. It is proving that the exporter can connect to a development database and expose one expected metric.

Oracle provides a local Docker Compose test stack with Oracle AI Database, the exporter, Prometheus, and Grafana, and the docs state that it provisions a dashboard. Treat that stack as local testing only. Do not expose sample database listener ports on shared or public hosts.

You can start up the demo system with a command like this – make sure you choose a password, don’t copy the example:

cd docker-compose
DB_PASSWORD='<choose-a-local-demo-password>' docker-compose up -d

Once the exporter is running and reachable from your shell, the first check is simple:

curl http://localhost:9161/metrics | grep oracledb_up

Expected shape:

oracledb_up{database="..."} 1

This proves that the exporter can observe the database from its own connection path. It does not prove application health, production readiness, or that every metric query succeeded. If oracledb_up is missing or zero, check exporter logs, database connectivity, credentials, privileges, service name, and metric configuration before trusting deeper panels.

You can log into the Grafana instance at http://localhost:3000 and you will need to enter “admin” as both the user and the password. It will ask you to create a new password. Then you can nagivate to “Dashboards” and open the “Oracle Dashboard”. You’ll see something like this:

What to look at first

Start with exporter health before interpreting database behavior. The default metrics include oracledb_exporter_last_scrape_error, oracledb_exporter_last_scrape_duration_seconds, and oracledb_exporter_scrapes_total.

A healthy oracledb_exporter_last_scrape_error value is 0. A nonzero value indicates exporter scrape errors, not necessarily a database outage. The cause might be credentials, network reachability, a timeout, a permissions problem, a custom metric query, or database load.

Then add a few database-side panels that help explain application symptoms.

oracledb_up is the basic availability signal from the exporter’s perspective:

oracledb_up

For sessions, Oracle’s 2.3.1 sample output shows oracledb_sessions_value with labels such as status and type, so this is a reasonable first query after validating your own output:

sum by (status, type) (oracledb_sessions_value)

Do not treat this as a direct count of active users. Database background sessions and configuration can make the result noisy. The useful question is whether the shape changes during the incident window: did sessions rise when the endpoint slowed down, did inactive sessions pile up, or did work shift after a deployment or ingestion job?

Activity metrics such as oracledb_activity_execute_count, oracledb_activity_parse_count_total, oracledb_activity_user_commits, and oracledb_activity_user_rollbacks can show whether workload changed. Graph activity metrics directly first. Only use rate() after confirming the metric type and behavior in the exporter output for your version, because the docs expose some cumulative-looking database activity values as gauges.

Wait-time metrics provide another useful triage layer. Default metrics include categories such as user I/O, system I/O, concurrency, application, commit, and network:

oracledb_wait_time_user_io
oracledb_wait_time_system_io
oracledb_wait_time_concurrency
oracledb_wait_time_application
oracledb_wait_time_commit
oracledb_wait_time_network

In the demo environment, there is probably not enough workload to see waits. In a real system, the value is in watching whether wait behavior changes during the same window as application latency.

The default metrics also include oracledb_top_sql_elapsed. Use it as a hotspot signal, not as a complete tuning workflow. In the sample, this metric includes labels such as SQL ID and truncated SQL text. That can be useful for triage, but it also raises label-cardinality and sensitive-text concerns. Review whether those labels are appropriate for your monitoring backend before enabling broad retention or alerting on them. You may not want all your SQL statement being stored in your monitoring backend!

Why AI applications make database metrics part of user experience

AI applications often make database behavior more visible to users, even when the application code hides the details. A RAG endpoint might perform vector search, filter JSON metadata, fetch relational records, update conversation memory, write audit rows, and call a model in a single request path. If one of those database steps slows down, the user experiences it as a slow answer.

Application metrics can show that the endpoint got slower. They may also show that the connection pool waited longer or that model latency was stable. But they usually cannot answer database-side questions by themselves:

Did sessions rise during ingestion? Did execute or parse activity change after a new retrieval strategy? Did waits move toward I/O, concurrency, commit, network, or application categories? Did a new SQL shape become a hotspot? Did the exporter itself start reporting scrape errors?

Oracle Database Metrics Exporter helps answer those questions in the same observability workspace used for service metrics. That does not make the exporter an AI-specific tool. It makes it useful for AI systems because AI request paths often combine application code, database retrieval, background ingestion, and model calls in ways that are difficult to debug from one layer alone.

Security basics before copy/paste becomes habit

The exporter needs database connectivity and credentials. For demos, environment variables are acceptable placeholders. For production-like setups, use configuration files, password files, vault integrations, wallet-based authentication, or platform secret management as appropriate.

There are several credential and authentication options, including username/password, environment-variable substitution, password files, OCI Vault, Azure Vault, HashiCorp Vault, Oracle Wallet with TLS or mTLS, Secure External Password Store, and external authentication. For mTLS connections, including Autonomous Database with mTLS, Oracle Wallet is required.

Privileges deserve the same care. For default metrics, the documentation lists SELECT_CATALOG_ROLE as one option and also lists object-level grants on specific catalog and dynamic performance views. Do not treat SELECT_CATALOG_ROLE as automatically least privilege. In production-like environments, create a dedicated monitoring user and grant the narrowest permissions that support the metrics you actually collect. For custom metrics, grant access only to the objects queried by those metrics.

Tune scrape interval and query timeout for your environment. Metric collection is database work. Its cost depends on the default metrics you enable, custom SQL, scrape frequency, query timeout, number of databases, network latency, and current database load. You should be aware that running the exporter is not “zero overhead” or “low overhead” – it depends on the metrics you define and how often you collect them. You should perform measurement in your own environment.

Network placement also matters. The exporter must reach the database listener. Prometheus or the OpenTelemetry Collector must reach the exporter endpoint. Grafana must reach the metrics backend, not necessarily the exporter. In production, keep database listeners and exporter endpoints inside the intended network boundary and expose only what your monitoring architecture requires.

How it fits with tools you already have

Oracle Database Metrics Exporter complements application telemetry; it does not replace it. Spring Boot Actuator and Micrometer show how the application experiences the database: connection pool wait time, request latency, error rates, JVM behavior, and application-specific counters. The exporter shows database-side signals. The useful workflow is to compare both in the same time window.

It also does not replace Oracle Enterprise Manager or deeper Oracle diagnostic workflows. Enterprise Manager remains a broader Oracle management and monitoring platform. Exporter metrics are a different integration primitive: a bridge into Prometheus-compatible developer and SRE metrics workflows.

For detailed SQL tuning, plan changes, workload analysis, and deeper database diagnosis, use the appropriate Oracle diagnostic tooling and DBA workflows, subject to your edition and licensing. Metrics can tell you where to look. They do not automatically explain every query plan, locking issue, schema design problem, or workload pattern.

The exporter path is most relevant when Prometheus-compatible metrics are the standard integration layer, or when platform teams want Oracle Database behavior available in the same metrics pipeline as the rest of their services.

Custom SQL collectors can also be useful, especially for application-specific signals. The tradeoff is that you own the query design, metric naming, labels, metric types, privileges, scrape endpoint, and operational behavior. A maintained database exporter reduces the need to build every one of those pieces from scratch.

The practical mental model

Think of Oracle Database Metrics Exporter as a narrow but valuable bridge:

Oracle Database behavior
→ exporter metric queries
→ scrapeable metrics endpoint
→ Prometheus-compatible storage
→ Grafana panels, alerts, and shared incident timelines

That bridge is useful because database behavior often explains application symptoms. It is especially useful for AI applications, where one slow response may involve retrieval queries, JSON filters, vector search, ingestion load, connection pools, and model calls.

Start small. Prove oracledb_up. Check exporter scrape health. Add one sessions panel. Add one activity or wait panel. Compare those panels against application latency during a real workload. Once the database appears in the same timeline as the application, the conversation changes from “the database was slow” to “here is what changed, when it changed, and where we should investigate next.”

Posted in Uncategorized | Tagged , , , , , , | Leave a comment

Build Your First SQL Property Graph In Oracle AI Database 26ai

The first article introduced the graph idea: vertices are things, edges are relationships, and properties describe both.

Now we will build one.

The easiest way to learn SQL property graphs in Oracle AI Database 26ai is to start with ordinary tables. That keeps the graph concrete. You can see the rows first, then see how the graph definition gives those rows a connected shape.

The point of this article is simple: you can create a queryable fraud graph on top of existing Oracle tables in one SQL statement, without duplicating the underlying data. By the end, you will have run the seed SQL, created BANK_GRAPH, and inspected the metadata Oracle stores for the graph.

In this article, we will create a small bank dataset and define a graph named BANK_GRAPH.

Start With Tables, Not Theory

Our fraud example has three tables:

  • customers, which stores customer names and risk tiers;
  • bank_accounts, which stores account records;
  • bank_transfers, which stores money movement from one account to another.

The transfer table is the key. Every transfer has a source account and a destination account. That is already an edge hiding in relational form.

This is why the example works well for a first graph. We do not have to invent a relationship. The relationship already exists in the schema; the graph definition just makes it explicit and queryable as a path.

The demo seed data includes a few useful patterns:

  • a 3-hop cycle: 101 -> 102 -> 103 -> 101;
  • a fan-in pattern where several accounts send money to account 105;
  • a two-hop chain where account 105 sends to 108, and 108 sends to 106;
  • customer risk tiers that we can join back to graph results later.

First, create the three tables, load the small dataset, and print row counts. Make sure you flip the switch to enable read/write so you can create new objects! If you do not, you will get an “insufficient privileges” error.

If you prefer to work in your own Autonomous Database Serverless instance, copy the same script into SQL Worksheet and run it there. SQLcl and SQL*Plus users can run the file directly from the command line.

When it finishes, you should see row counts for customers, accounts, and transfers.

This first tutorial deliberately avoids Object Storage and bulk-loading APIs. Those matter for production data, but they add noise to a beginner lesson whose real goal is graph modeling.

Define The Graph Vocabulary

The graph definition answers four questions:

  • Which table contains vertices?
  • Which table contains edges?
  • Which columns identify each vertex and edge?
  • Which properties should graph queries expose?

For this demo, bank_accounts becomes the vertex table and bank_transfers becomes the edge table.

Here is the essential graph definition:

CREATE OR REPLACE PROPERTY GRAPH bank_graph
VERTEX TABLES (
bank_accounts KEY (account_id)
LABEL account
PROPERTIES (account_id, customer_id, account_name, balance)
)
EDGE TABLES (
bank_transfers KEY (transfer_id)
SOURCE KEY (src_account_id) REFERENCES bank_accounts (account_id)
DESTINATION KEY (dst_account_id) REFERENCES bank_accounts (account_id)
LABEL transfer
PROPERTIES (transfer_id, amount, transfer_ts, channel, note)
);

The VERTEX TABLES clause says that rows in bank_accounts are account vertices. The KEY clause says account_id uniquely identifies each vertex.

The EDGE TABLES clause says that rows in bank_transfers are transfer edges. The SOURCE KEY and DESTINATION KEY clauses make the edge direction explicit.

That direction matters. A transfer from account 101 to account 102 is not the same as a transfer from 102 to 101.

The PROPERTIES clauses are equally important. They decide which table columns are visible to graph queries. If you leave a column out, it remains available to ordinary SQL, but it will not be projected as a graph property in the graph pattern. For a teaching demo, keep the exposed properties small and obvious.

Next, create BANK_GRAPH and run the metadata checks shown below.

If you are using ADB-S instead, copy the code into the same schema where you ran the seed script.

Inspect The Graph

After the graph is created, inspect the database metadata.

The first check is simple:

SELECT graph_name
FROM user_property_graphs
WHERE graph_name = 'BANK_GRAPH';

That confirms that Oracle Database knows about the graph object.

Next, inspect the graph elements:

SELECT graph_name, element_name, element_kind, object_name
FROM user_pg_elements
WHERE graph_name = 'BANK_GRAPH'
ORDER BY element_kind, element_name;

You should see the account vertex element and the transfer edge element.

Then inspect labels and properties. This is useful when you are teaching or debugging because it shows the vocabulary that graph queries can use.

SELECT graph_name, label_name, property_name
FROM user_pg_label_properties
WHERE graph_name = 'BANK_GRAPH'
ORDER BY label_name, property_name;

The output should include properties such as ACCOUNT_ID, ACCOUNT_NAME, BALANCE, AMOUNT, CHANNEL, and TRANSFER_TS.

This metadata check is more than a sanity test. If a later graph query cannot see amount or account_name, the metadata views tell you whether the graph definition exposed those properties in the first place.

What The Graph Does Not Do

Creating BANK_GRAPH does not change the purpose of the base tables. Applications can still use customers, bank_accounts, and bank_transfers as ordinary relational tables.

The graph also does not decide what is fraudulent. It gives you a way to ask connected-data questions. In later articles, those questions become signals: cycles, hub accounts, accounts in the middle of transfer chains, PageRank scores, and connected groups. A real fraud workflow would combine those signals with business rules, investigation history, KYC data, device information, and human review.

The graph helps you see connection patterns. It does not magically replace risk modeling.

A Quick Check Before Moving On

Before you continue, make sure three things are true.

First, the base tables have rows. Second, USER_PROPERTY_GRAPHS shows BANK_GRAPH. Third, USER_PG_LABEL_PROPERTIES shows the properties you expect to query later, especially ACCOUNT_ID, ACCOUNT_NAME, AMOUNT, CHANNEL, and TRANSFER_TS.

Those checks are small, but they save time. Most beginner graph-query mistakes come from one of two places: the graph object was not created in the schema you are querying, or the property you want was not exposed in the graph definition.

If those checks pass, we can focus on patterns instead of setup. That is exactly where graph starts to feel different from ordinary table queries in practice.

Why This Matters

The graph is now ready, but the original data has not stopped being relational data.

That is the point. You can still query customers, bank_accounts, and bank_transfers with ordinary SQL. The graph adds a new query surface for connected patterns.

In the next article, we will use GRAPH_TABLE to ask questions like:

  • Which accounts receive money from many sources?
  • Which accounts sit in the middle of transfer chains?
  • Which accounts are part of transfer cycles?
  • Which suspicious graph results belong to high-risk customers?

Those questions are where the graph model starts to pay off.

Next step: move to Article 3, where the graph stops being metadata and starts answering fraud questions.

Try The Demo

This article had two runnable moments that you already saw above!

First, load the small bank dataset. That gives you ordinary relational tables with customers, accounts, and transfers. Second, create BANK_GRAPH over those tables and inspect the graph metadata.

Use the embedded FreeSQL runners above if you want the fastest path. Step 1 loads the data; Step 2 creates and checks the graph. If you prefer to use your own ADB-S instance, copy the same SQL into SQL Worksheet and run the scripts in the same order.

When both steps finish, you should have a graph named BANK_GRAPH in your schema. Keep it there for the next article. We will use that same graph to query hubs, two-hop chains, cycles, and customer-risk joins.

Posted in Uncategorized | Tagged , , | Leave a comment

Add persistent chat memory to your Spring AI chatbot with Oracle (part 2 of 4)

The first version of this application could already answer support questions against real policy data. Oracle Vector Store was retrieving the right documents before generation, and the responses were grounded in what was actually in the database rather than in whatever the model happened to know from training.

But every request started from zero.

Send “My name is Maya” and then immediately ask “What’s my name?” and the assistant had no idea. Not because retrieval failed. Because the application itself was stateless. Conversation history was never stored anywhere. Each request arrived with only the current message and the policy documents that matched the similarity search. The moment the response was sent, everything about the exchange was gone.

That is the gap between a search engine with a chat interface and something that actually behaves like a conversation. Episode 2 closes it. Watch it here,

RAG and memory are different problems

It is worth being precise about this before looking at the code, because the two ideas are easy to conflate.

RAG — retrieval-augmented generation — gives the model access to information it would not otherwise have. When a customer asks about the refund policy, the application runs a similarity search against Oracle Vector Store, finds the relevant policy document, and includes it in the prompt. The model can then answer from real data rather than from training knowledge.

Conversational memory is a different thing entirely. It is not about what the model knows. It is about what the application remembers. Without memory, each request is independent. With memory, the application accumulates context across exchanges — facts the user has shared, questions already asked, decisions already made — and makes that context available to the model on every subsequent request.

Both matter. RAG makes individual answers accurate. Memory makes the conversation coherent. Episode 1 solved the first problem. Episode 2 solves the second.

What changes

The overall architecture stays the same. Spring Boot, Spring AI, Oracle Vector Store, and OpenAI are all unchanged from Episode 1. The only addition is persistent chat memory backed by a JDBC repository in Oracle.

One new dependency:

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>

One new configuration property:

spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always

That creates the SPRING_AI_CHAT_MEMORY table in Oracle on startup. No migration script, no manual DDL.

The memory bean

ChatMemoryConfig.java creates a ChatMemory bean backed by JdbcChatMemoryRepository:

@Bean
@ConditionalOnMissingBean(ChatMemory.class)
public ChatMemory chatMemory(DataSource dataSource, MemoryProperties memoryProperties) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(JdbcChatMemoryRepository.builder()
.dataSource(dataSource)
.build())
.maxMessages(memoryProperties.maxMessages())
.build();
}

MessageWindowChatMemory keeps a sliding window of the most recent messages per conversation. The window size is maxMessages, set to 100 in application.yml. As the conversation grows, older messages fall off the window so the prompt does not grow without bound.

The @ConditionalOnMissingBean annotation is worth noting. Tests substitute an in-memory ChatMemory implementation in TestSupportConfiguration, so the test suite can load the full application context without requiring a live database. The @ConditionalOnMissingBean is what makes that substitution work — if a test has already registered a ChatMemory bean, this bean definition is skipped.

Adding memory to the chat client

Episode 1 already had a QuestionAnswerAdvisor wired into the ChatClient for RAG retrieval. Episode 2 adds MessageChatMemoryAdvisor in front of it:

return chatClientBuilder
.defaultSystem(assistantProperties.systemPrompt())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
questionAnswerAdvisor
)
.build();

The order matters, and it is worth understanding why.

Advisors in Spring AI form a chain that wraps around the model call. Each advisor can modify the request before it reaches the model and can inspect or modify the response on the way back out. They execute in registration order on the way in, and in reverse order on the way out.

Registering MessageChatMemoryAdvisor first means it runs before QuestionAnswerAdvisor. On the way in, the memory advisor loads the stored conversation history for the current conversation ID and adds those messages to the request. By the time QuestionAnswerAdvisor runs its similarity search, the full conversation context is already present. The retrieved policy documents are then appended on top of that. By the time the model sees the request, it has both the conversation history and the relevant policy knowledge.

If the advisors were in the wrong order — retrieval first, memory second — the memory advisor would add conversation history after retrieval had already run. That would still work for basic memory, but the similarity search would only have the current message to work with, not the full conversation context. For most queries that does not matter, but it can affect retrieval quality for follow-up questions where the topic is implicit from prior context rather than stated explicitly in the current message.

Scoping memory by conversation

Without scoping, all users would share the same memory, which would be disastrous. The chat endpoint now requires an X-Conversation-Id header:

@PostMapping("/chat")
public ChatResponse chat(
@RequestHeader("X-Conversation-Id") String conversationId,
@Valid @RequestBody ChatRequest request
) {
return new ChatResponse(
assistantService.answer(sanitizeConversationId(conversationId), request.message()),
"stateful-rag"
);
}

The caller is responsible for supplying and maintaining the conversation ID. The application does not generate or track session identifiers — that is intentional. For this demo, any string works. In production you would tie the conversation ID to an authenticated user session, but that is out of scope here.

The conversation ID is passed into the advisor chain at call time:

chatClient.prompt()
.user(message)
.advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.chatClientResponse();

Spring AI reads that parameter, loads the matching rows from SPRING_AI_CHAT_MEMORY, prepends them to the prompt, and writes the new exchange back to the table when the call completes. The scoping is entirely key-based. Two requests with different conversation IDs read and write completely separate sets of rows. There is no shared state between them.

Trying it

Start the application and send two messages with the same conversation ID:

curl -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"My name is Maya."}'
curl -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"What'''s my name?"}'

The second request should come back with “Maya”. Now try the same question with a different conversation ID:

curl -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-2"
-d '{"message":"What'''s my name?"}'

The assistant should not know. The rows for demo-1 are invisible to a request arriving with demo-2. Memory does not leak between conversations.

Inspecting what Oracle is storing

There is a debug endpoint that reads directly from ChatMemory:

curl "http://localhost:8080/api/v1/debug/memory?conversationId=demo-1"

It returns the stored messages with their roles:

[
{"role": "user", "text": "My name is Maya."},
{"role": "assistant", "text": "Nice to meet you, Maya! How can I help you today?"}
]

You can also go directly to the table:

SELECT * FROM SPRING_AI_CHAT_MEMORY;

These are ordinary rows. Conversation ID, role, content, timestamp. There is nothing opaque about the storage format. This also means the memory is durable — restart the application and the conversation history is still there, because it is in Oracle and not in process memory.

The model did not become smarter between episodes. The application stopped throwing away state.

Oracle is now storing three kinds of data

After Episode 1, Oracle was already handling two things: relational order data in CUSTOMER_ORDER, and vectorized policy knowledge in the Spring AI Oracle Vector Store table.

Episode 2 adds a third: conversational state in SPRING_AI_CHAT_MEMORY.

All three live in the same database. There is no separate vector store running alongside, no in-memory session cache to manage, no additional infrastructure to operate. The application connects to one Oracle instance and that instance holds everything the assistant needs — business data, knowledge, and conversation history.

That consolidation is not just operationally convenient. It also opens up possibilities that would be harder to achieve across separate systems. If you later wanted to run a query that joined conversation history with relational order data — for example, to find conversations where a customer mentioned an order that was then returned — all the data is in the same place. You do not have to reconcile records across multiple stores.

What is next

The assistant can now retrieve knowledge and remember conversations. But it still cannot act on any of the backend data. If a customer asks about the status of order ORD-1002, the assistant can only explain what the return policy says. It cannot look up the actual order.

Episode 3 changes that. Spring AI tool calling lets the assistant call narrow backend methods — order lookup, return initiation, support ticket creation — with the backend owning all the validation logic. The model handles the conversation. The backend handles the rules.

Repo: https://github.com/markxnelson/shopassist/tree/EP2

Posted in Uncategorized | Tagged , , , , | Leave a comment

What Is a Graph in Oracle AI Database 26ai?

If you have ever asked, “How is this thing connected to that thing?”, you have already asked a graph question.

A customer sends money to an account. That account sends money to two other accounts. One of those accounts sends money back to the original customer. In ordinary SQL, you can query each transfer. But when the real question is about chains, loops, hubs, and hidden intermediaries, the shape of the question becomes graph-shaped.

Oracle AI Database 26ai makes that graph shape part of the database instead of a separate side project. You can model a property graph over existing relational tables, query it with SQL graph syntax, join the results back to relational data, and then move into Graph Studio or PGX when you need visualization or heavier analytics.

Did you know that graphs were added to the SQL standard? Read about it here.

This series starts from zero. You do not need graph theory. You do not need a separate graph database. You need a basic comfort with SQL and a practical problem where relationships matter.

There are two ways you can try the runnable examples in this series. The first path is to use FreeSQL and this will work just fine for most of the examples: creating the tables, creating the graph, and querying patterns with GRAPH_TABLE. The second path is your own Autonomous Database Serverless instance on OCI for the full analytics chapter, because the DBMS_OGA algorithm examples require packages that are not available in the FreeSQL environment.

By the end of this article, you should be able to explain five ideas well enough to understand the graph DDL in the next article: vertex, edge, label, property, and directed relationship. You should also understand why Oracle’s 26ai graph approach is accessible from ordinary SQL instead of starting in a separate graph-only tool.

That is the only conceptual load for now. We will save weighted paths, algorithms, PGX, and natural-language querying for later articles, when those ideas have a working graph underneath them.

The Graph Mental Model

A graph has two main parts: vertices and edges.

A vertex is a thing. In a banking example, a vertex might be an account. In a social network, it might be a person. In a supply chain, it might be a warehouse, supplier, shipment, or part.

An edge is a relationship between things. In the banking example, a transfer from account 101 to account 102 is an edge. The edge has direction because money moved from one account to another.

Both vertices and edges can have properties. An account vertex can have an account id, customer id, account name, and balance. A transfer edge can have an amount, timestamp, channel, and note.

Labels give those things names in the graph model. In this series, account vertices use the label account, and transfer edges use the label transfer. Labels make graph patterns readable because you can ask for accounts connected by transfers instead of thinking only in table and column names.

That gives us a simple model:

  • account is a vertex;
  • transfer is an edge;
  • transfer direction goes from source account to destination account;
  • transfer amount and timestamp are edge properties;
  • customer id and balance are vertex properties.

The point is not to replace tables. The point is to describe relationships over data you already have.

That distinction helps avoid a common beginner mistake. You are not deciding whether the account data is “relational” or “graph.” It is relational data that also has a graph interpretation. Oracle lets you keep both views of the same facts.

Why Graph Questions Feel Different

Relational databases are excellent at storing and querying structured facts. A transfer table can tell you that account 101 sent 500 dollars to account 102. A customer table can tell you that account 102 belongs to a medium-risk customer.

Graph questions start when the relationship pattern matters.

For example:

  • Which accounts receive money from many different sources?
  • Which accounts sit in the middle of two-hop transfer chains?
  • Which accounts participate in round-trip transfer cycles?
  • Which high-risk customers are connected to suspicious paths?

You can answer some of these with ordinary joins, especially when the path length is fixed and short. But the queries become harder to read as soon as you want to ask about paths, cycles, or repeated relationship patterns.

That is where GRAPH_TABLE becomes useful. It lets you describe a graph pattern and return the matches as rows. Once the graph match is back in row form, you can use normal SQL again: filter it, aggregate it, join it, and sort it.

What Changed In 26ai

In Oracle AI Database 26ai, SQL property graphs are native database objects. You create them with CREATE PROPERTY GRAPH, and you query them with SQL graph syntax such as GRAPH_TABLE.

The important beginner idea is this: a SQL property graph is metadata over database objects. You do not have to copy all of your rows into a separate graph store just to start asking graph questions. The graph definition says which tables provide vertices, which tables provide edges, how the keys connect, which labels to use, and which properties to expose.

For the bank example in this series, the relational data is still stored in ordinary tables:

  • customers
  • bank_accounts
  • bank_transfers

The graph object simply gives those tables graph meaning:

  • bank_accounts becomes account vertices;
  • bank_transfers becomes transfer edges;
  • src_account_id and dst_account_id define edge direction.

That is why graph in 26ai is a good fit for developers and DBAs who already work with Oracle Database. You can start with SQL, keep the data where it is, and add graph-shaped queries where they help.

Why Not RDF?

Oracle supports RDF graphs too, but this series is about property graphs. RDF is the better fit when the work centers on formal semantics, ontologies, inferencing, and standards-based knowledge representation. Property graphs are the better starting point here because the question is operational and concrete: how are these accounts connected, and what suspicious patterns do those connections form?

The Bank Fraud Story We Will Use

The rest of this series uses a small bank-fraud style example. It is intentionally tiny so you can understand every row.

The demo has customers, accounts, and transfers. Some transfers form a simple cycle. Other transfers create a fan-in pattern where several accounts send money to the same destination. Another chain places a high-risk customer in the middle.

That gives us enough data to teach useful graph ideas without hiding the lesson inside a huge dataset.

Here is the shape of the data we will use throughout this series:

customers bank_accounts bank_transfers
---------- ------------- --------------
customer_id -> customer_id transfer_id
risk_tier account_id -> src_account_id
account_name -> dst_account_id
balance amount
transfer_ts
channel

The SQL property graph gives those tables this connected shape:

(account)-[transfer]->(account)

That one pattern, account connected to account by transfer, is enough to teach all four articles’ worth of graph concepts: paths, cycles, hubs, chains, ranking, connected groups, and hybrid SQL joins.

Where This Series Is Going

The next article builds the graph. We will create the tables, load seed data, define BANK_GRAPH, and inspect the graph metadata.

After that, we will query fraud patterns with GRAPH_TABLE. Then, in ADB-S, we will add in-database algorithms with DBMS_OGA and use Graph Studio to visualize the same graph.

The goal is not to memorize every graph feature. The goal is to build a practical mental model:

  1. start with relational data;
  2. define a SQL property graph;
  3. query graph patterns with SQL;
  4. join graph results to ordinary data;
  5. move into Graph Studio or PGX when you need visualization or deeper analytics.

That is the zero-to-hero path.

Try The Demo

The fastest way to make the ideas concrete is to run the tiny bank demo yourself. The first runnable step creates three ordinary relational tables:

  • customers
  • bank_accounts
  • bank_transfers

Those tables hold the facts. The next step creates BANK_GRAPH, a SQL property graph over the account and transfer rows. Nothing is copied into a separate graph store; the graph definition gives the existing rows a connected shape.

Note: this will ask you to login with your oracle.com account since it writes to the database, not just reads. It’s totally free. I recommend you read through the SQL first to understand what it does, then click on the “Run Script” button to execute it, and then scroll through the output to see what happened. Feel free to play around with it and change it however you like!

In the next article, you’ll load the seed data and create the graph. If you prefer to use your own Autonomous Database Serverless instance, copy the same SQL into your SQL Worksheet and run it there. Either way, keep the data small at first. The goal is to see the graph model clearly before adding larger datasets, algorithms, or visualization.

Once BANK_GRAPH exists, the later examples will use the same graph to find inbound hubs, transfer chains, cycles, PageRank scores, connected groups, and weighted paths.

Posted in Uncategorized | Tagged , , | 1 Comment

Build a stateless RAG chatbot with Spring AI and Oracle (part 1 of 4)

Hi everyone!

I just posted the first video in this series about building AI assistance with memory and agency. In this video, we built a simple customer support assistant using Spring Boot, Spring AI, and Oracle Database. It answers policy questions, but the key idea is that those answers are grounded in data stored in Oracle rather than coming from the model itself.

In this post I want to walk through how the application is actually put together, focusing on the pieces that make that retrieval flow work.

The full project is here: https://github.com/markxnelson/shopassist

If you clone the repo and open it up, it looks like a normal Spring Boot application. The difference is how Oracle is used underneath, and how that gets wired into the chat flow.

A good place to start is with how the database is brought up locally.

In compose.yaml, the application relies on Spring Boot’s Docker Compose support to start Oracle automatically:

services:
  oracle:
    image: gvenzl/oracle-free:23.26.1-slim
    ports:
      - "1521"
    environment:
      ORACLE_PASSWORD: oracle
      APP_USER: shopassist
      APP_USER_PASSWORD: shopassist

There’s nothing unusual here, but it’s worth noting that this is just a standard Oracle instance. There’s no separate “vector database” running anywhere. The same database is going to store both relational data and vector embeddings.

Once that container is running, the next piece is how the application connects to it.

In application.yml, you can see both the datasource configuration and the vector store configuration side by side:

spring:
  datasource:
    url: jdbc:oracle:thin:@localhost:${oracle.port}/FREEPDB1
    username: shopassist
    password: shopassist
  ai:
    vectorstore:
      oracle:
        initialize-schema: true
        index-type: NONE
        distance-type: COSINE

This is where Oracle AI Vector Search becomes visible in the application.

The initialize-schema flag tells Spring AI to create the underlying tables needed to store embeddings. Once that happens, the database is no longer just storing rows – it’s storing vectors that can be searched using similarity.

The distance-type: COSINE setting controls how similarity is calculated, which is what allows us to retrieve “related” documents even when the wording is different.

With that in place, the next question is how data actually gets into the vector store.

That happens in DataSeeder.java, and this is one of the more important pieces of the application because it defines what the system actually knows.

If you look at the seeder, you’ll see the policy documents being added like this:

vectorStore.add(List.of(
    new Document("""
        POL-RETURN-01:
        Returns are accepted within 30 days of purchase with a valid receipt.
        Refunds are issued to the original payment method.
        """),
    new Document("""
        POL-DAMAGE-01:
        Damaged items must be reported within 48 hours of delivery.
        Customers may request a replacement or refund.
        """),
    new Document("""
        POL-SHIPPING-01:
        Shipping delays may occur due to weather or carrier issues.
        Customers will be notified of significant delays.
        """),
    new Document("""
        POL-REFUND-01:
        Refunds are typically processed within 5-7 business days after approval.
        Processing times may vary by payment provider.
        """)
));

Each of these documents is embedded and stored in Oracle. That’s what allows us to later take a user’s question, convert it into a vector, and find the most similar documents using Oracle AI Vector Search.

The same class also seeds some relational order data, which we expose through /api/v1/orders. That isn’t used by the chatbot yet, but it becomes important later when we start combining retrieval with structured queries.

Once the data is in place, the next piece is how a request flows through the system.

If you open AgentController.java, the endpoint itself is intentionally simple:

@RestController
@RequestMapping("/api/v1/agent")
public class AgentController {
    private final ChatClient chatClient;
    public AgentController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }
@PostMapping("/chat")
    public String chat(@RequestBody ChatRequest request) {
        return chatClient.prompt(request.message()).call().content();
    }
}

On its own, this just looks like a thin wrapper around an LLM call. There’s no obvious retrieval happening here.

That’s because the retrieval is introduced through how the ChatClient is configured:

QuestionAnswerAdvisor.builder(vectorStore)
    .searchRequest(SearchRequest.builder().topK(3).build())
    .build();

When chatClient.prompt(...).call() is invoked, this advisor intercepts the request, runs a similarity search against Oracle, and retrieves the top three matching documents.

Those documents are then injected into the prompt that gets sent to the model.

So when a user asks something like “How long do refunds take?”, the system first retrieves the refund policy from Oracle and then asks the model to generate an answer based on that context.

One small addition that makes this much easier to see is the debug endpoint. Instead of going through the full chat flow, this endpoint lets you query the vector store directly and inspect what Oracle returns.

The controller for that looks like this:

@RestController
@RequestMapping("/api/v1/debug/policies")
public class PolicyDebugController {
    private final VectorStore vectorStore;
    public PolicyDebugController(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }
    @GetMapping("/search")
    public List<Document> search(@RequestParam String q) {
        return vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(q)
                .topK(3)
                .build()
        );
    }
}

There’s nothing complicated here, but it’s doing something important. Instead of calling the model, it calls vectorStore.similaritySearch(...) directly using the same search configuration.

So when you hit:

GET /api/v1/debug/policies/search?q=refund

you’re seeing exactly what Oracle returns for that query. The same embeddings, the same cosine similarity, the same top-K logic—just without the LLM step.

This makes it much easier to reason about the system. If the wrong documents show up here, the model never had a chance to produce the right answer. If the right documents show up here but the answer is still off, then you know to look at prompt construction instead.

At this point, the application is doing something useful, but it’s also very clearly limited because it is completely stateless.

If you try something like:

My name is Maya.

and then follow it with:

What’s my name?

the system won’t be able to answer. Each request is handled independently, and the only context available is the current message plus whatever documents were retrieved from Oracle.

There is no conversation history, and nothing is stored between requests.

This is the key distinction to understand at this stage. Oracle AI Vector Search gives us a way to retrieve relevant knowledge from stored data, but it does not give us memory. The system can answer policy questions because those policies are embedded and searchable, but it cannot remember something you just told it.

That limitation is intentional, because it sets up the next step. In the next episode we’ll introduce persistent chat memory, add conversation identifiers, and start maintaining context across requests.

For now, this version gives us a clean, grounded foundation: a Spring Boot service where Oracle stores embeddings, performs similarity search, and provides the context that the model uses to generate responses. Everything else we add will build on top of that.

Posted in Uncategorized | Tagged , , , , | 1 Comment

Exploring Oracle’s new AI Agent Memory Python Library with OpenAI

Hi everyone!

In this post, I want to show you a small but useful demo application that uses Oracle AI Agent Memory from Python. The complete code for this example is in the agent-memory repository. You can learn more about Oracle AI Agent Memory on the Oracle website.

The demo is a customer support assistant. That is a nice shape for an agent memory example because it gives us all the things agents usually need to remember: who the user is, what happened before, which device or account is involved, what the current case state is, and whether this new problem sounds like an earlier one.

The important point is that oracleagentmemory is the memory layer. It is not tied to one agent framework. You can use it with different frameworks and SDKs. This particular sample uses the OpenAI SDK for the agent-style tool-calling loop, and it uses Oracle AI Agent Memory as the durable memory backend.

In other words, OpenAI drives the agent turn. Oracle stores and retrieves the memory.

Let’s walk through it.

What we are building

The sample application does five things:

  • starts a local Oracle Database Free container
  • creates the Oracle AI Agent Memory managed schema
  • creates a small companion schema for customer, device, case, policy, JSON state, and graph data
  • runs a scripted support conversation with memory-aware tool calls
  • prints a database inspection report so we can see where the memory went

The scenario centers on Alex, a support user with a River House account and a Model X router. Alex had a prior Wi-Fi dropout issue. Later, Alex comes back and says video calls are unstable again. The agent needs to figure out whether that sounds related, what facts it already knows, what relationships matter, and what to do next.

That gives us a realistic demo without needing a real ticketing system, CRM, or router telemetry feed.

Before you begin

You need Python, Docker, uv, and an OpenAI API key.

The quickstart from the repository is:

cp .env.example .env
# Edit .env and set OPENAI_API_KEY.
uv sync
uv run agent-memory-demo run

The demo uses gvenzl/oracle-free:23.26.1-slim-faststart by default. That is helpful for a local demo because the database starts faster than a normal first-start image.

The repository also sets these defaults:

  • OPENAI_MODEL=gpt-5-mini
  • OPENAI_EMBEDDING_MODEL=text-embedding-3-small
  • OPENAI_MEMORY_LLM_MODEL=gpt-5-mini
  • ORACLE_MEMORY_TABLE_PREFIX=OAM_DEMO_
  • ORACLE_APP_TABLE_PREFIX=OAM_DEMO_APP_

The two prefixes matter. The OAM_DEMO_ tables are managed by Oracle AI Agent Memory. The OAM_DEMO_APP_ tables are the companion business tables created by this sample application.

That separation makes the demo easier to understand. We can see what the library owns, and we can also see the normal application data that the agent works with.

Why agent memory is not just one thing

When people first talk about agent memory, they often mean one thing: chat history. That is useful, but it is not enough.

A useful agent may need several kinds of memory:

Memory kindWhat it means in this demoOracle storage usedWhen to use it
Working or thread memoryThe current and previous support messagesRelational rows in managed Agent Memory tablesUse this when the agent needs conversation continuity.
Durable fact memoryPreferences, facts, and case summaries that should survive the current conversationManaged Agent Memory records, plus vector chunks for retrievalUse this when the agent should remember something later.
Profile memoryUser and agent profilesRelational rowsUse this for stable actor information such as user preferences or agent identity.
State memoryThe mutable status of a support caseJSON in the app-owned case tableUse this when the shape of the state may evolve over time.
Relationship memoryUser to account to device to case to policy pathsSQL Property Graph over relational vertex and edge tablesUse this when the important question is about connected things.
Similarity memoryPrior cases or memories that are semantically close to the current issueOracle VECTOR columns in record chunksUse this when the same thing may be described in different words.

That is the main architectural idea in the demo. Different memory types have different access patterns, so they should not all be forced into the same shape.

Relational data is great when identifiers, constraints, ownership, and joins matter. JSON is great when the shape of state changes as the case moves forward. Graph is great when paths and relationships are the point. Vector data is great when similarity matters more than exact matching.

The nice thing here is that all of those can live in Oracle Database. The agent does not need a separate relational database, graph database, document database, and vector database just to remember one support case.

The repository structure

The application code lives under src/agent_memory_demo.

The key files are:

FileWhat to look for
cli.pyThe Typer commands: run, interactive, inspect-db, verify-memory, and reset-db.
container.pyThe local Oracle container lifecycle.
config.pyEnvironment variable loading and defaults.
memory.pyCreation of the OracleAgentMemory client.
agent.pyThe OpenAI tool-calling loop and tool schemas.
tools.pyTool handlers for memory search, saving memory, context, JSON state, graph paths, and inspection.
schema.pyThe app-owned relational, JSON, and graph schema.
seed.pyDeterministic demo data.
inspect.pyDatabase inspection output, including vector storage evidence.

The sample is intentionally small, but it is not a toy in the sense of hiding the database. It shows the database because that is the point of the demo.

Creating the memory client

The memory setup happens in memory.py.

The demo creates an OracleAgentMemory client with:

  • an Oracle database connection
  • an embedding model
  • an LLM model for memory extraction and summaries
  • a schema policy
  • a table name prefix

The schema policy is important. Normal startup uses SchemaPolicy.CREATE_IF_NECESSARY, so the managed Agent Memory schema is created if it is not already there. The reset-db command uses the recreate policy for an explicit destructive reset.

That gives the sample a clean local developer workflow. You can run it, inspect it, reset it, and run it again without needing a manually installed database.

The agent loop

The OpenAI side of the sample lives in agent.py.

The agent loop sends a user message, instructions, and a list of function tools to the OpenAI SDK. When the model returns tool calls, the application executes the local Python handler, sends the tool output back, and repeats until the model produces final text.

The useful part is that the tools map to different memory operations:

ToolWhat it demonstrates
search_memoryScoped Agent Memory search.
save_memoryExplicit durable memory writes.
get_contextA thread context card.
update_case_stateJSON state updates in the support case table.
find_related_caseVector-backed semantic retrieval of similar case memories.
explain_relationshipsSQL Property Graph traversal.
inspect_memory_tablesDatabase evidence for the demo.

This is a useful pattern for agent applications. The model does not get direct database access. It gets tools. Each tool has a focused job, a scoped input shape, and a handler that decides what database operation is safe and appropriate.

The companion schema

The sample creates app-owned tables for customers, devices, support cases, policies, graph vertices, and graph edges.

This is separate from the managed Agent Memory schema. That is a good design choice because most real applications already have business data. Agent memory should not replace that data. It should work with it.

The support case table uses JSON for mutable state. A case might start as open, then get a next action, then become escalation-ready, then later get a resolution. That kind of state is structured, but it can change over time. JSON is a good fit.

The graph tables show relationships. Alex owns an account. The account has a router. The router has a case. The case uses a policy. That is exactly the kind of question where graph traversal is easier to read than a pile of joins.

The managed Agent Memory tables store threads, messages, memory records, actor profiles, and record chunks. The record chunks are where the vector-backed similarity story becomes visible.

Run the scripted demo

Start with the main command:

uv run agent-memory-demo run

The run command starts the Oracle container, creates demo schema objects, seeds deterministic data, runs a scripted support conversation, shows memory tool usage, prints the final assistant answer, and includes a database inspection report before the container is removed.

There are a few things to watch for in the output.

First, the demo creates both user and agent profiles. That shows profile memory, not just chat memory.

Second, it creates an initial thread and stores messages. That gives the agent working memory and a durable record of the conversation.

Third, it saves explicit memory. The demo records facts like Alex’s router and contact preferences.

Fourth, it creates a second thread for a follow-up problem and shows the difference between broad thread matching and exact thread matching. That is a subtle but important behavior. Sometimes you want memories from the same user and agent across threads. Sometimes you only want the current thread.

Fifth, it shows scope isolation. A search scoped to another user should not see Alex’s memories.

Finally, the OpenAI tool calls are printed with their JSON arguments and compact results. That makes the agent loop much easier to reason about because you can see what the model asked for and what the database returned.

The output is color-coded:

  • green for user messages
  • magenta for assistant messages
  • yellow for OpenAI tool calls and arguments
  • blue for Oracle database, graph, and tool-result evidence
  • cyan for progress and memory setup or search visibility

That may sound like a small thing, but it makes the demo much easier to follow while it runs.

Run the interactive demo

The repository also includes an interactive mode:

uv run agent-memory-demo interactive

This starts a memory-enabled assistant using the same command-scoped Oracle container lifecycle. It seeds the same companion data and stores your turns in a scoped Agent Memory thread.

One practical detail: the container is command-scoped. It exists while the command is running and is removed when the command exits. So if you want to inspect the database manually, leave the interactive session open.

The README says to wait for output like this:

Started Oracle demo database at localhost:32838/FREEPDB1
Seeded companion relational, JSON, graph, and policy data.
Interactive Oracle AI Agent Memory demo. Type 'quit' to exit.
you>:

Then, before inspecting the database, ask a prompt that creates some memory activity:

For user_id=user_alex and agent_id=support_agent, inspect memory tables and tell me what you can see in one sentence.

Now keep that terminal open and inspect the database from another terminal.

Inspect the database

There is a command for a quick database evidence report:

uv run agent-memory-demo inspect-db

This starts a temporary Oracle container, seeds the deterministic companion data, prints table counts, JSON case state, and graph paths, and then tears the container down.

One thing to know: inspect-db does not create Agent Memory records. That means managed memory table counts are expected to be zero for that command. Use run or verify-memory when you want to populate and inspect memory and vector chunk tables.

For hands-on SQL inspection, keep the interactive session open and connect from a second terminal. Replace the port with the one printed by your run:

sql agent_memory_demo/AgentMemoryDemo1@localhost:32838/FREEPDB1

A good first query is to list the demo tables:

SELECT table_name
FROM user_tables
WHERE table_name LIKE 'OAM_DEMO%'
ORDER BY table_name;

Then look at the columns and data types:

SELECT table_name, column_name, data_type
FROM user_tab_columns
WHERE table_name LIKE 'OAM_DEMO%'
ORDER BY table_name, column_id;

That is where the storage story becomes concrete. You should see the normal relational columns, the JSON columns in the app-owned tables, and, after running a memory-populating command, the vector-related storage in the managed record chunk table.

The graph edge indexes are also useful to inspect:

SELECT index_name, table_name, column_name
FROM user_ind_columns
WHERE index_name LIKE 'OAM_DEMO_APP_GRAPH_EDGE%'
ORDER BY index_name, column_position;

To see the JSON case state, run:

SELECT case_id,
title,
json_value(state_json, '$.status') AS status,
json_value(state_json, '$.next_action') AS next_action,
json_value(state_json, '$.escalation_ready') AS escalation_ready
FROM OAM_DEMO_APP_CASE
ORDER BY case_id;

This is a good example of why JSON is useful here. The case state is still queryable from SQL, but the state document can evolve as the workflow evolves.

Now look at the graph tables:

SELECT vertex_id, vertex_type, label
FROM OAM_DEMO_APP_GRAPH_VERTEX
ORDER BY vertex_type, vertex_id;
SELECT source_vertex_id, relationship_type, target_vertex_id
FROM OAM_DEMO_APP_GRAPH_EDGE
ORDER BY edge_id;

And confirm that the property graph exists:

SELECT object_name, object_type
FROM user_objects
WHERE object_name = 'OAM_DEMO_APP_PROPERTY_GRAPH';

Finally, after running a memory-populating command, look at the managed Agent Memory tables:

SELECT *
FROM OAM_DEMO_MEMORY
FETCH FIRST 5 ROWS ONLY;
SELECT *
FROM OAM_DEMO_RECORD_CHUNKS
FETCH FIRST 5 ROWS ONLY;

This query shows the vector columns:

SELECT column_name, data_type
FROM user_tab_columns
WHERE table_name = 'OAM_DEMO_RECORD_CHUNKS'
AND data_type = 'VECTOR';

And this one shows vector indexes:

SELECT index_name, index_type
FROM user_indexes
WHERE table_name = 'OAM_DEMO_RECORD_CHUNKS'
ORDER BY index_name;

That is the part I like most in this demo. We are not just saying that memory is persistent. We can actually look at the tables and see how different kinds of memory are represented.

Verify graph and vector behavior

The verify-memory command is a nice acceptance check:

uv run agent-memory-demo verify-memory

It seeds explicit Agent Memory records, runs graph traversal, runs vector-backed similarity search, and prints metadata evidence for the managed OAM_DEMO_RECORD_CHUNKS table.

The expected similar prior router case is case_wifi_dropout_001.

That matters because the follow-up issue does not have to use the exact same words as the earlier issue. Vector search can connect “video calls freeze” with a prior router dropout case because the meaning is similar.

This is the right place to use vectors. You are not asking for the one row with a known primary key. You are asking, “Have we seen something like this before?”

When to use each Oracle data type

Here is the practical version.

Use relational tables for the things you must identify and constrain: users, accounts, devices, cases, policies, threads, messages, and profile rows. Relational data gives you keys, constraints, indexes, joins, and ownership boundaries. That is still the backbone of most useful applications.

Use JSON for flexible state. In this sample, support case state lives in JSON because the state can change as the workflow changes. The update_case_state tool uses JSON_MERGEPATCH to update the state document without replacing the whole application model.

Use graph for connected context. If the agent needs to understand that Alex owns an account, the account has a router, the router has a case, and the case uses a policy, graph traversal makes that relationship path explicit. In this sample, explain_relationships uses a SQL Property Graph query to return user-account-device-case-policy paths.

Use vectors for similarity. If the user describes the same issue with different words, exact search is not enough. Vector search lets the agent find semantically similar memories and prior cases. In this sample, case summary memories are embedded into record chunks, and the find_related_case tool searches those chunks through Oracle Agent Memory.

The real value is not that any one of these exists. The value is that the sample can use all of them together.

Reset the demo

If you want to exercise the destructive reset path, the repository includes this command:

uv run agent-memory-demo reset-db

That resets the app-owned companion schema and recreates the managed Agent Memory schema. It is a demo command, not something to point at a production schema.

What this sample teaches

There are a few patterns here that are worth carrying into real applications.

First, keep the memory backend separate from the agent framework. This demo uses the OpenAI SDK tool loop, but the memory concepts are not OpenAI-specific. The agent needs tools. The memory system needs scoped APIs. Those two things meet at a clean boundary.

Second, scope everything. The demo uses user, agent, and thread boundaries. It also shows that another user should not see Alex’s memories. That is not just a demo flourish. It is table stakes for real multi-user agents.

Third, use the right data shape for the job. Chat messages, durable memories, JSON state, graph relationships, and vector chunks are not the same thing. Treating them differently makes the system easier to reason about.

Fourth, inspect the database. Agent demos can feel magical if all you see is a final answer. This demo is better because it shows the rows, JSON state, graph paths, and vector storage evidence. That makes the behavior testable and explainable.

Wrap up

We built and inspected a memory-enabled support assistant using oracleagentmemory, the OpenAI SDK, and a local Oracle Database container.

The sample shows working memory through threads and messages, durable memory through explicit memories and extracted facts, profile memory for users and agents, JSON state for support cases, relationship memory through SQL Property Graph, and similarity memory through vector-backed record chunks.

The important idea is simple: agents need more than chat history. They need memory that is durable, scoped, queryable, and connected to the data the application already trusts.

This demo gives you a compact way to see that pattern end to end.

Posted in Uncategorized | Tagged , , , , , , , , | Leave a comment

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.

Posted in Uncategorized | Tagged , , , , , , , | Leave a comment

Building an Authorization Server with Spring Boot 3 and Oracle Database

Hi 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 3.x code line. The repository currently uses Spring Boot 3.5.x, Java 21, Spring Authorization Server through the Spring Boot starter, and the Oracle Spring Boot starters. A future post will cover the move to Spring Boot 4.x, including the associated new versions of Spring Framework and Spring Security.

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.

Here is the dependency section from pom.xml:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-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.liquibase</groupId>
            <artifactId>liquibase-core</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-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-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>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>oracle-free</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

There are a couple of things to point out here.

First, spring-boot-starter-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.

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 =
                OAuth2AuthorizationServerConfigurer.authorizationServer();

        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.

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 new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }
        } else {
            return new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }
    }

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 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 new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }

        // 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 new ResponseEntity<>(null, HttpStatus.OK);
                } else {
                    return new ResponseEntity<>(null, HttpStatus.NO_CONTENT);
                }
            } catch (Exception e) {
                return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }
        } else {
            return new ResponseEntity<>(null, HttpStatus.FORBIDDEN);
        }
    }

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 new ResponseEntity<>(null,
                        HttpStatus.OK);
            } catch (Exception e) {
                return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }

        } else {
            return new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }
    }

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 new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }

        } else {
            return new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }
    }

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 3 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.

In a future post, we will take this example forward to the Spring Boot 4.x line and look at the associated new versions of Spring Framework and Spring Security.

Posted in Uncategorized | Tagged , , , , , , | Leave a comment

Building a Spring Boot Starter for Oracle Spatial

Hi everyone!

Over the last few days I have been working on a new Spring Boot starter for Oracle Spatial.  I did this work with two other real developers, and three AI coding assistants, and I thought it would be interesting to write up the story of how it came together.

This was a good example of how building a starter is not just about getting code to compile or getting a sample app to run. It is also about API shape, developer expectations, reviewer feedback, naming, documentation, tests, and all of those little choices that decide whether something feels natural or awkward once another developer actually tries to use it.

We ended up in a much better place than where we started, but not because the first design was perfect. We got there because the reviews were good, the feedback was honest, and we were willing to change direction once it was clear the API could be better.

So this post is a bit of a behind-the-scenes look at that process.

What we were trying to build

The goal sounded simple enough on paper:

  • create a Spring Boot starter for Oracle Spatial
  • make it easy to work with SDO_GEOMETRY
  • keep the programming model GeoJSON-first
  • provide a sample application that shows realistic use

The kinds of queries we had in mind were the kinds of queries almost everyone reaches for first:

  • store a point or polygon
  • fetch it back as GeoJSON
  • find landmarks near a point
  • find landmarks within or interacting with a polygon

So from the beginning this was going to involve Oracle Spatial operators such as:

  • SDO_UTIL.FROM_GEOJSON
  • SDO_UTIL.TO_GEOJSON
  • SDO_FILTER
  • SDO_RELATE
  • SDO_WITHIN_DISTANCE
  • SDO_NN
  • SDO_GEOM.SDO_DISTANCE

We also wanted a sample app that felt approachable. We used a small San Francisco landmark dataset so the sample would be easy to understand and a little bit fun to play with.

Where we started

The first version of the starter leaned in the direction that I think a lot of us would naturally start with: helper utilities.

We had one piece focused on converting GeoJSON to and from Oracle Spatial, and another piece focused on generating the bits of SQL you need for common spatial predicates and projections.

On one level, that worked.

It absolutely solved real problems:

  • you did not have to remember the exact SDO_UTIL.FROM_GEOJSON(...) call
  • you did not have to hand-type the common predicate shapes every time
  • you could centralize the default SRID and distance unit

If you already like building SQL with JdbcClient, it was useful.

But there was a catch. The public API was still basically returning strings. And once that got called out in review, it was hard to ignore.

The review comment that changed everything

The most important reviewer comment was not about a syntax error or a missing test. It was about the shape of the API.

The feedback was basically: if this is a Spring Boot starter, why is the main experience a SQL string builder?

That was the right question.

It is one thing to have Spring-managed beans. It is another thing entirely to have a Spring-native programming model.

At that point the starter was Spring-managed, but not really Spring-native. Yes, you could inject the beans, but what you got back were still string fragments that you had to splice together yourself.

That triggered the main redesign.

The moment that made the redesign easier

One thing that helped a lot is that the original API had not been released yet.

That is a huge advantage.

It meant we did not need to protect old method names or preserve an API shape just because it already existed. We were free to ask a better question: if we were designing this from scratch for Spring developers, what should it look like?

Once we reframed it that way, the answer became much clearer.

What we changed

The big change was moving away from public string-builder beans and toward one main Spring JDBC integration bean:

  • OracleSpatialJdbcOperations

Instead of treating Oracle Spatial as a collection of string helper methods, we moved to a design where application code injects one bean and then creates typed spatial query parts:

  • SpatialGeometry
  • SpatialExpression
  • SpatialPredicate
  • SpatialRelationMask

This ended up being a much better fit for the way Spring JDBC code is usually written.

You still write SQL. We did not try to invent a whole DSL. But the spatial pieces now carry more meaning and stay connected to the bind process instead of floating around as anonymous fragments.

That was the key design improvement.

Why this felt better almost immediately

One of the things I liked about the redesign is that the sample application got better almost immediately once the API got better.

That is usually a good sign.

When an API is awkward, the sample tends to look awkward too. You can hide that for a little while, but not for long.

With the redesigned API, the service code started to read much more naturally. In the sample’s findNear flow, for example, we now create:

  • a SpatialGeometry
  • a distance expression
  • a within-distance predicate

and then build the SQL around those named pieces.

That may sound like a small thing, but it makes a big difference when someone is reading the sample for the first time and trying to understand the intended usage pattern.

Instead of “here is some mysterious Oracle SQL,” the code reads more like “here is the geometry we are searching around, here is the distance we want to project, and here is the predicate we want to apply.”

That is a much better teaching story.

The sample app became part of the design process

I think sometimes people talk about sample applications as though they are just an afterthought. In practice, for a starter like this, the sample is part of the design process.

It answers questions like:

  • does the API feel natural in a real service?
  • are the method names understandable?
  • can we explain this to somebody without too much ceremony?
  • does the code read like Spring, or does it read like a thin wrapper over raw SQL?

Our sample app is a simple REST service built around landmarks in San Francisco.

It has endpoints for:

  • creating a landmark
  • fetching a landmark by id
  • finding nearby landmarks
  • finding landmarks inside or interacting with a polygon

We also spent some time improving the seed data because I wanted the sample to be a little more recognizable and useful. So the sample now includes landmarks like:

  • Ferry Building
  • Union Square
  • Golden Gate Park
  • Oracle Park
  • Salesforce Tower
  • Transamerica Pyramid
  • Coit Tower

That may not be the most “architectural” part of the work, but it helps make the sample feel real instead of abstract.

One small enum that mattered more than I expected

Another design decision that turned out to matter a lot was replacing raw relation-mask strings with an enum:

  • SpatialRelationMask

This came directly out of review feedback.

The problem with raw strings is obvious once someone points it out: a developer can type something like "INTERSECTS" and everything looks fine until runtime, when Oracle tells them that is not a valid mask.

That is exactly the kind of thing a good starter should help with.

By introducing an enum, we made that part of the API:

  • safer
  • easier to discover
  • harder to misuse

It is a small API element, but it made the whole thing feel more deliberate.

Distance became first-class because it had to

One of the early reviews also pointed out that if we wanted to serve real spatial use cases, we needed first-class support for distance calculations.

That was absolutely right.

A lot of real applications want some version of:

  • find nearby things
  • return the distance
  • order by distance

If the starter handled filtering but not distance calculation, it would always feel like it stopped just short of the most useful scenario.

So SDO_GEOM.SDO_DISTANCE became part of the main API design rather than something developers would have to improvise themselves.

I think that made the starter much more credible for real use.

Documentation ended up being more important than I expected

I do not mean that in a generic “docs matter” way. I mean that for this starter in particular, the documentation had to explain a mental model, not just list methods.

The most useful documentation change we made was adding a clear distinction between:

  • what gets injected as a Spring bean
  • what gets created per query

That turned out to be the right way to explain the design.

You inject:

  • OracleSpatialJdbcOperations

And then per query you create:

  • SpatialGeometry
  • SpatialExpression
  • SpatialPredicate

Once we started explaining it that way, the docs got much easier to follow.

We also added concrete query pattern examples for:

  • inserts
  • GeoJSON projection
  • filter + relate
  • distance-ordered proximity queries

Those examples matter because they show how the design is actually supposed to be used. For a starter, that is every bit as important as the Javadoc.

Some of the polish came from the less glamorous review comments

Not all of the useful review feedback was high-level architecture. Some of it was the kind of practical feedback that makes software much more solid.

A few examples:

The sample needed better error handling

At one point the sample app would throw an exception and return a 500 if a caller passed an invalid spatial relation mask.

That is a perfectly believable bug in a sample. It is also exactly the kind of thing a reviewer should call out, because the sample is part of the product story.

We fixed it so that invalid masks now produce a proper 400 Bad Request with a useful message.

The tests needed to be more Spring-native too

We got feedback to use @ServiceConnection in the Testcontainers-based tests, and that was good advice. It made the tests more consistent with current Spring Boot style and reduced some manual wiring.

We also adjusted the integration test to run as the app user rather than system, which is a much better representation of how the code should actually work.

The SQL setup needed to be repeatable in CI

This is one of those things that only becomes obvious once CI starts yelling at you.

We hit issues with setup SQL being run more than once and colliding with existing objects or metadata. That led to a round of cleanup to make the test setup more idempotent and more robust.

That is not the glamorous part of building a starter, but it is absolutely part of shipping one.

What we deliberately did not do

There were also some things we chose not to do in this round of work.

I think that is worth talking about because saying “not yet” is often part of good design.

We did not try to eliminate SQL entirely

Even after the redesign, application code still assembles SQL statements with JdbcClient.

That was intentional.

We wanted to make the API more Spring-native, but not disappear into a custom DSL or pretend SQL no longer exists. This is still Spring JDBC. SQL is still the right abstraction level. The important improvement was to stop making the public API itself a string-builder API.

There is still room to improve the ergonomics in the future, especially around making it harder to forget a bind contributor. Reviewers called that out too, and I think they are right. But that felt like a v2 refinement, not something we needed to solve before this version was useful.

We did not add every possible spatial operation

Another good review point was that buffer generation would be useful too. I agree.

But once again, that felt like feature expansion rather than a cleanup item for this iteration.

There is always a temptation to keep adding one more thing once the codebase is open and fresh in your mind. In this case I think the right move was to get the core API shape right, get the sample and docs into good condition, and leave some room for future work.

What I think we ended up with

At the end of all of this, what we have is not just a set of helper methods. It is a small but coherent Spring Boot story for Oracle Spatial.

The final result includes:

  • a starter that auto-configures a Spring JDBC-oriented spatial bean
  • a typed API for spatial query parts
  • safer handling of SDO_RELATE masks
  • first-class distance support
  • a sample REST app that demonstrates realistic usage
  • docs that explain the mental model, not just the method list
  • integration tests that run against Oracle AI Database 26ai Free with Testcontainers

And maybe the biggest thing for me is that it now feels like something I would want another Spring developer to pick up and try.

That was not as true of the first version.

What we are already thinking about for v2

Even though I feel good about where this landed, we have also been pretty careful to write down the things that did not belong in this iteration.

There are at least two clear areas for a possible v2.

The first is improving the ergonomics around binding and query composition.

Right now the design is much better than the original string-builder approach, but there is still a pattern like this:

spatial.bind(
        jdbcClient.sql("select ... " + distance.selection("distance")
                + " from landmarks where " + within.clause()),
        distance, within)

That is a reasonable place to be for a JDBC-oriented starter, but it is not hard to imagine a future version that tightens that up further and makes it harder to forget a bind contributor.

The second is expanding the supported spatial operations.

One of the most obvious candidates there is buffer generation with SDO_GEOM.SDO_BUFFER. That came up during review, and I think it is a very good candidate for a future enhancement. There are also broader questions about whether repository-style integrations or richer mapping options might make sense once we have real experience with how people use the current API.

But I want to be careful here. Just because we can imagine a v2 does not mean we should rush into it. What I would really like at this point is to get feedback from actual users first.

I would much rather learn from people who try the starter on real spatial workloads than guess too aggressively about what the next abstraction should be. Maybe the next thing we need is buffer support. Maybe it is better row mapping. Maybe it is a tighter JdbcClient integration. Maybe it is something we have not even thought about yet.

So yes, we do have a v2 plan taking shape. But before we take that next step, I would love to hear from users and see how this first version holds up in practice.

Working with AI coding assistants

Like many people today, I am doing more and more work with AI Coding assistants.  For this piece of work, these are the participants and the roles they played:

  • me: collaborated with GPT to write the specification, set the standards, conventions, etc., guided the whole process, read and reviewed code myself, helped make, or made design decisions
  • GPT-5.4: acted as the primary developer, wrote most of the code, reflected on its own work, helped write and refine the specification, processed review comments and planned, helped with the design
  • Claude Code: acted as a “senior architect” who reviewed the code with a focus on technical aspects and gave detailed feedback and recommendations
  • Gemini (3 thinking/pro): acted as the “product manager” who reviewed the project from the point of view of how well it addressed the need, if it exposed the right features and capabilities, and how useful it would be for a spatial user
  • two human colleagues: acted as reviewers and developers who provided valuable feedback on the code and the design

As we worked through the project together, I saved a lot of the generated plans and reviews in the .ai directory because I think those are valuable artifacts.  We’re obviously using AI more, and we have no intention of hiding it, so why not save these for future use?  Seems like the right thing to do.  We did have a conversation about this before coming to the conclusion that we should save them and put them in the public repo.

I also want to mention that there were several cycles in the project.  We started discussing the requirements and writing a plan.  We reviewed this and iterated on it two or three times before we ever started writing any code.  By “we”, I mean me and the three AIs.  It was interesting to see the slightly different opinions of the AIs and the different areas they focused their attention on.  Just like human developers, the different opinions and insights were useful to help us get to a better result.

After we started development, we continued to cycle through reviews, feedback, updates.  At one point, as discussed above, we decided to redesign – this was a result of human feedback.  The AIs certainly did a good job, and they are getting better all the time, but I do still feel that they are not as good at doing novel things as they are at doing things that have been done before, and they perhaps don’t consider (or at least mention) the implications of architectural decisions as much as some human developers do, at least in my experience to date.  But I am sure they will continue to evolve rapidly.

I guess I’d just say – if you have not tried working in a team with multiple AI coding assistants yet, give it a go!

Why I wanted to write this up

I wanted to tell this story because I think it is a pretty normal and healthy example of how good API work actually happens.

You start with an idea.

You build something that is useful but not yet quite right.

Somebody asks a question that exposes the weakness in the design.

You resist it for a minute.

Then you realize they are right.

Then the real design work starts.

That is basically what happened here.

And I think the result is better precisely because we were willing to let the reviews change the direction of the code rather than just polish the original design.

Wrapping up

I am really happy with how this turned out.

Not because it is finished forever. It is not. There are still good future directions here. But the core design now feels solid, and the sample, tests, and docs all tell a coherent story.

That is what I wanted from this starter.

If you are working with Spring Boot and Oracle Database, and you have spatial use cases in mind, I think this gives you a pretty nice starting point. And if you are designing your own starter or library, maybe the bigger lesson here is that reviewer feedback is not just something to “address.” Sometimes it is the thing that helps you find the real design.

More on this soon.

Posted in Uncategorized | Tagged , , , , , , , , , , | Leave a comment