What I built
identityCore is a self-hosted Identity & Access Management (IAM) service — the kind of thing you'd normally reach for Auth0, Keycloak, or Cognito for, built from the ground up in Spring Boot to actually understand how the pieces fit together.
Stack: Spring Boot 3.3.5, Spring Security 6.3.4, Spring Data JPA, PostgreSQL (prod) / H2 (dev), Thymeleaf, HikariCP, BCrypt.
The feature set
-
Three login paths, one identity model: form login, Google (OIDC), GitHub (OAuth2) — all resolving to the same
UserEntity -
RBAC as data, not constants:
RoleEntityandPermissionEntityare real JPA entities, editable through an admin UI, not@PreAuthorize("hasRole('ADMIN')")scattered everywhere with no single source of truth -
REST + MVC in the same app:
AuthApiController/UserApiControllerreturn a consistentApiResponse<T>wrapper, whileDashboardController/ProfileController/RoleControllerserve Thymeleaf views -
One exception handler to rule them all:
GlobalExceptionHandlercatchesApplicationException,ValidationException,ResourceNotFoundException,AuthenticationExceptionand maps each to the right HTTP status — no leaking stack traces, no inconsistent error shapes across endpoints
Why two OAuth2 user services, not one
This is the part most tutorials skip. Google and GitHub are not the same protocol even though Spring Security's oauth2Login() makes them feel identical:
.oauth2Login(oauth -> oauth
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService) // handles GitHub (plain OAuth2)
.oidcUserService(customOidcUserService) // handles Google (OIDC)
)
)
-
GitHub returns an opaque
access_token— no identity claims baked in. You have to callGET /userafterward, and if the user's email is private, that field comes backnull. MyCustomOAuth2UserServiceexplicitly calls/user/emailsto get a real, verified address instead of faking one. -
Google returns an
id_token— a signed JWT withemail,email_verified, andnamealready inside it.CustomOidcUserServicereads it directly, no extra network round-trip.
Both funnel into one shared OAuth2UserProvisioner that does the actual find-or-create against the DB — so the "is this a new user" logic exists in exactly one place, regardless of which provider they came from.
The bug that only showed up in production
Everything passed locally. The moment I pointed my own domain at the app through a tunnel (TLS terminated at the edge, plain HTTP to the app on port 8080), OAuth2 login died with redirect_uri_mismatch.
Root cause: Spring Security builds the OAuth2 redirect URI from what it thinks the incoming request's scheme was. Behind a tunnel/reverse proxy, the app only ever sees the internal http://localhost:8080 hop — so it built http://yourdomain.com/login/oauth2/code/google while Google had https://yourdomain.com/... registered. Same code, same config, silently broken by where it was running.
Fix — one line:
server.forward-headers-strategy=framework
This tells Spring to trust the X-Forwarded-Proto / X-Forwarded-Host headers the tunnel already sends, and rebuild URLs with the correct scheme. No proxy config needed on my end — most tunnels send these headers by default.
Lessons
-
OAuth2 ≠ OIDC. OAuth2 is authorization (what you can access). OIDC adds a purpose-built identity layer (
id_token) on top of it for authentication (who you are). Treating them identically works right up until it doesn't. -
A working demo and a working deployment are different problems. The exact same
SecurityConfigbehaves differently depending on what's between the client and your app. - Centralize provisioning. Two auth paths, one user-creation function — not two copies of "does this user exist" logic that can drift apart.
Repo (branch with the OAuth2 work): github.com/Tharvesh2026/identityCore_J25
Happy to go deeper on the RBAC design or the exception-handling setup if there's interest.
Top comments (0)