SecurePay: three Spring Boot services (Authorization Server :9000, gateway A :8080, data service B :8081). Part 1 follows one real request end to end. Part 2 explains every piece of code, why it was written, and what data travels where.
1 · Live request journey
Pick a scenario, then click a box or use Next. The right panel shows exactly what changed after each step (green = added, yellow = changed, red = removed). Arrow keys also work.
State after this step
+ added~ changed− removed
Dashed arrows are the one-time-per-key-set fetch of the auth server's public keys (/oauth2/jwks): A and B each cache them and verify tokens locally, so the auth server is not called on every request. The dev profile makes a fresh key on every auth-server start, so tokens issued before a restart stop verifying.
2 · Big picture
Who does what
Postman ──login + consent──► Auth Server :9000 ──JWT (RS256, 5 min)──► Postman
Postman ──Authorization: Bearer JWT──► Service A :8080 ──same JWT──► Service B :8081 ──► H2 file DB
Service
Job
Knows about
payment-authorization-server :9000
Logs users in, asks for consent, issues signed JWTs, publishes its public keys
Users (nithin, alice), the client payment-client, the signing key
payment-service-a :8080
Gateway. Checks the token and scope, forwards the call to B with the same token, maps B's errors
No database. No payment logic
payment-service-b :8081
Stores payments in H2, checks the token itself, enforces ownership
The PAYMENTS table
Three ideas that explain the whole design
Only the auth server creates tokens. A and B only verify them, using the public key. They never see a password.
Scope decides whether you may call an endpoint (SCOPE_payment.read to GET, SCOPE_payment.write to POST). Ownership decides which payments you may see (the token's sub must equal the payment's owner).
B never trusts A. Anyone can call B directly on 8081, so B validates the JWT itself and reads the user from that JWT, not from anything A sends.
3 · How a token is born (Authorization Server)
The login → consent → code → token flow, and the code behind each step
1. Client opens GET /oauth2/authorize?response_type=code&client_id=payment-client&scope=payment.read payment.write&redirect_uri=...
2. Not logged in → 302 /login (LoginUrlAuthenticationEntryPoint)
3. User posts POST /login username + password + _csrf → session cookie
4. Back to GET /oauth2/authorize → consent page (first time) or a redirect with ?code=... (consent remembered)
5. User approves POST /oauth2/authorize client_id + state + scope checkboxes → 302 redirect_uri?code=XYZ
6. Client swaps POST /oauth2/token Basic payment-client:secret + grant_type=authorization_code + code
7. Server answers {"access_token":"eyJ...", "token_type":"Bearer", ...} (a signed JWT)
What each bean in SecurityConfig (auth server) does, and why it exists
Bean / method
What it does
Why it's written this way
authorizationServerSecurityFilterChain@Order 1
Owns the OAuth URLs (/oauth2/authorize, /oauth2/token, /oauth2/jwks, OIDC discovery). Browsers without a login are sent to /login
The OAuth protocol is implemented by filters, not controllers. securityMatcher(getEndpointsMatcher()) makes this chain apply only to those URLs
defaultSecurityFilterChain@Order 2
Everything else: login page via formLogin, all other pages need a login
Catches what chain 1 didn't claim. Provides the form that step 3 posts to
Loads the key pair with alias securepay from a PKCS12 file, fixed kidsecurepay-1
Tokens survive restarts and the private key stays out of the repo. It refuses to start without the two variables
jwtDecoder(jwkSource)
Lets the auth server read tokens sent to it (e.g. /userinfo)
Takes the JWKSource by type, so it works with either signing bean. A and B build their own decoder from issuer-uri
authorizationServerSettings
issuer("http://localhost:9000")
Written into every token's iss. Fixed so localhost vs 127.0.0.1 can't create two issuers
What is inside the token
header : {"alg":"RS256","kid":"…"}
claims : {"iss":"http://localhost:9000","sub":"nithin",
"scope":["payment.read","payment.write"],"iat":…,"exp": iat + 300, …}
signature = RSA-sign(header.claims, private key) ← only the auth server has the private key
4 · Service A (the gateway)
Every piece of A and why it exists
Class / method
What it does
Why it's written this way
SecurityConfig.securityFilterChain
Open: /actuator/health, /error. POST /payments/** needs SCOPE_payment.write, GET /payments/** needs SCOPE_payment.read. Everything else needs a token. Stateless, CSRF off
Rules are checked top to bottom, first match wins, so the specific rules sit before anyRequest().authenticated(). SCOPE_ is the prefix Spring adds to each scope. No session or cookie carries identity, so CSRF protection has nothing to protect
.oauth2ResourceServer(o -> o.jwt(...))
Installs the JWT filter and builds a decoder from spring.security.oauth2.resourceserver.jwt.issuer-uri
One line turns A into a resource server: it fetches the JWKS once, caches it, and checks signature, exp and iss on every request
PaymentController
POST /payments, GET /payments/{id}, GET /payments. Each just calls PaymentService
Controllers stay thin. A has no @Valid: validation is B's job, and A passes B's 400 through
PaymentServiceImpl
Calls B with webClient and .block(). In getPayment, a 404 from B is caught and rethrown as B's PaymentNotFoundException
.block() keeps the call on the request thread, which is what lets the token relay find the caller's JWT. Translating the 404 gives A one clean exception type to map
WebClientConfig.webClient
Reactor Netty client with 2 s connect and 3 s response timeouts, base URL http://localhost:8081, and ServletBearerExchangeFilterFunction
Without the connector the timeouts are silently ignored and a hung B would hold A's threads. The filter copies the caller's bearer token onto the outgoing call, so B sees the real user
GlobalExceptionHandler
Turns exceptions into JSON: 404 for not-found, 401/403 from B, B's own status for anything else, 503 when B is unreachable, 400 for bad JSON, 404 for unknown routes, 500 as the last resort
Spring picks the most specific handler. The not-found handler was once commented out, so B's 404 fell into the catch-all and came out as a 500
Gotcha in WebClientConfig
The method takes WebClient.Builder builder but returns builder() (a static import), which creates a new builder. The injected one is never used. It works, but to use Spring Boot's builder (which carries tracing instrumentation) write builder.baseUrl(...)… without parentheses and drop the static import.
5 · Service B (data + ownership)
Every piece of B and why it exists
Class / method
What it does
Why it's written this way
SecurityConfig.securityFilterChain
Same rules as A (scopes, JWT, stateless)
B is a full resource server on its own, because it can be called directly
Matches only the H2 console (PathRequest.toH2Console()), permits it, allows framing from the same origin
The console has its own login and can't carry a bearer token. Confining these relaxations to a dev-only chain keeps the API strict. Outside dev this chain doesn't exist
Payment entity
id, accountNumber, amount, owner
owner stores the creator's JWT sub
PaymentRepository
JpaRepository + findByOwner(String)
Spring Data builds WHERE owner = ? from the method name. No SQL written
PaymentController
POST is @Valid @RequestBody, GET by id, GET all
Bean Validation rejects bad input (e.g. amount ≤ 0) before any business code runs
For a JWT login getName() is the sub. The owner comes from the verified token, never from the request body, so a client can't claim someone else's payment
create@Transactional
Validates, rejects a duplicate id, sets the owner, saves
One transaction for check + insert. A simultaneous duplicate is caught by the database's primary key and mapped to 409
Someone else's payment behaves exactly like a missing one: 404, not 403. A 403 would confirm that the id exists
getAllPayments
findByOwner(currentUser())
The list only ever contains the caller's payments
GlobalExceptionHandler
404 PAYMENT_NOT_FOUND, 409 DUPLICATE_PAYMENT (also for a database key violation), 400 validation / malformed body
One JSON shape for every error: timestamp, status, error, message, path
6 · Data at each hop
What the data looks like as it travels (POST /payments)
Hop
What travels
Postman → A
POST http://localhost:8080/payments Authorization: Bearer eyJ… · Content-Type: application/json body {"paymentId":"OWN-1","accountNumber":"acc-123","amount":25.00}
Inside A
Jackson builds PaymentRequest(paymentId, accountNumber, amount:BigDecimal). The verified token becomes a JwtAuthenticationToken in SecurityContextHolder (authorities SCOPE_payment.read, SCOPE_payment.write)
A → B
POST http://localhost:8081/payments with the sameAuthorization: Bearer header and the same JSON body, re-serialized from PaymentRequest
A's (or B's) JWT filter → BearerTokenAuthenticationEntryPoint; the controller is never reached
403
Valid token but the wrong scope (e.g. read-only token on POST)
AuthorizationFilter → BearerTokenAccessDeniedHandler in A
404
Payment missing or owned by someone else
B: PaymentNotFoundException → 404. A catches B's 404 and returns its own 404. Unknown route: A/B NoResourceFoundException
409
Duplicate payment id
B (DuplicatePaymentException or a database key violation); A passes it through
400
Validation failure (amount ≤ 0), malformed JSON
B validates and A passes it through; A itself rejects broken JSON
503
B down, connection refused, connect or response timeout
A: WebClientRequestException → bDown
500
Anything unexpected
The catch-all handler. Seeing this for a not-found means a handler is missing
8 · Profiles: dev vs default
What changes between local development and everything else
dev (local)
default
Auth server signing key
Generated in memory. Tokens die when the auth server restarts
PKCS12 keystore. Needs KEYSTORE_PATH (e.g. file:C:/keys/securepay.p12) and KEYSTORE_PASSWORD; alias must be securepay
B's H2 console
http://localhost:8081/h2-console (JDBC jdbc:h2:file:./data/paymentdb, user sa, password password)
Off, and its security chain isn't registered
mvn spring-boot:run "-Dspring-boot.run.profiles=dev" # or $env:SPRING_PROFILES_ACTIVE="dev"
.\scripts\start-all.ps1 # starts all three, sets dev for you
Start order matters: auth server → B → A.
9 · Testing
Postman collections and how to run them
Collection (in postman/)
Use
Payment-ServiceA-Automated
Main suite. Logs in as nithin, alice and a read-only nithin by script, then tests A: 401/403, create/get/list, ownership (404), duplicate (409), validation (400), malformed body, unknown route. Optional folder for 503 (stop B first)
Payment-Ownership-Automated
Same login automation, ownership tests straight against B
Payment-Microservice-OAuth2, PaymentMicroService
Manual walkthrough: browser for login and consent, copy the code, exchange it
Open the collection and press Run collection. Tokens last 5 minutes and die on a dev auth-server restart, so just re-run.
Consent is remembered until the auth server restarts. After the first approval the consent step shows skipped, which is expected.
Each run uses fresh payment ids, so repeated runs don't collide.
10 · Known limits
What this project does not do yet
Old rows have no owner. Payments saved before ownership existed have owner = NULL and are invisible. Delete payment-service-b/data/, or UPDATE PAYMENTS SET OWNER='nithin' WHERE OWNER IS NULL.
sub must be a person. A client-credentials token has sub = <client id>, so payments made that way belong to the client.
Learning-only security shortcuts:withDefaultPasswordEncoder(), in-memory users and clients, a fixed secret.
A depends on B's classes (it reuses PaymentNotFoundException), so A's build needs B's module.
Not built yet: client credentials for settlement-job, and distributed tracing.