A universal Spring Boot / Spring Security authentication client. It puts an internal user store,
OAuth2 / OIDC, SAML 2.0 and LDAP behind a single SecurityFilterChain, with a login page
that lets the user pick between whichever of them you have turned on.
|
Warning
|
Pre-1.0 and moving. Treat the configuration surface as unstable. |
The obvious way to support four mechanisms is four SecurityFilterChain beans. That does not work:
chains are matched in order and the first match wins, so the rest never run. UniAuth instead builds
one chain carrying form login, OAuth2 login and SAML login together.
-
Internal and LDAP are form-based. They share one username/password form and are told apart by the
AuthenticationProviderchain — each contributes a provider, and Spring Security tries them in turn. That is what lets you keep a couple of local break-glass accounts next to a directory. -
OAuth2 and SAML are redirect-based. Each registration keeps its own entry-point URL, so the chooser page only has to render a link per registration.
Add the starter:
<dependency>
<groupId>org.alexmond</groupId>
<artifactId>uniauth-spring-boot-starter</artifactId>
<version>4.1.0.2</version>
</dependency>That is all a build needs: the starter resolves entirely from Maven Central, and LDAP and SAML are both optional dependencies you add only if you use them.
SAML costs a consumer more than the other mechanisms, which is why it is opt-in. OpenSAML — needed
by spring-security-saml2-service-provider — is not published to Maven Central, so a build that
speaks SAML has to declare the Shibboleth repository as well as the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-saml2</artifactId>
</dependency><repositories>
<repository>
<id>shibboleth-releases</id>
<url>https://build.shibboleth.net/maven/releases/</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>It must be Boot’s SAML starter rather than spring-security-saml2-service-provider on its own:
Boot 4 moved SAML autoconfiguration into its own module, and the Spring Security artifact alone
gives you the filters with no property binding — so spring.security.saml2.relyingparty.* is read
by nobody and saml2Login never installs, silently.
Then enable whichever mechanisms you want. Nothing is on by default except the two that need no configuration of their own:
uniauth:
enabled: true # required — the starter installs nothing until asked
internal:
enabled: true
users:
- username: alice
password: "{noop}s3cret" # delegating encoder — prefix the algorithm
roles: [ USER, ADMIN ]
ldap:
enabled: true # also add spring-boot-starter-ldap — it is an optional dependency
url: ldap://directory.example.com:389/dc=example,dc=com
user-dn-patterns: [ "uid={0},ou=people" ]
group-search-base: ou=groups
# OAuth2 and SAML reuse Spring Boot's own binding — UniAuth consumes the repositories
spring:
security:
oauth2:
client:
registration:
google:
client-id: ...
client-secret: ...
saml2:
relyingparty:
registration:
okta:
...That last part is deliberate: UniAuth does not re-declare registration properties for OAuth2 and
SAML. It reads the ClientRegistrationRepository and RelyingPartyRegistrationRepository that Boot
already builds, so the whole upstream configuration surface stays available and stays documented by
Spring Boot.
uniauth-examples holds runnable applications, one per way of consuming the starter. The internal
store works with nothing else running; the LDAP path needs a directory, because the examples no
longer embed one — an in-process server is a test fixture, and scoping it as one is what surfaced
the failures a real directory produces (a non-world-readable ACL answering no such object, for
one). Point uniauth.ldap.url at a directory, or leave LDAP off and sign in as alice.
| Module | What it shows |
|---|---|
|
Start here. Server-rendered pages, the provider chooser, and the approval queue. |
|
API-first: no templates, providers as JSON, 401 instead of a redirect. |
./mvnw -Pdefault -DskipTests install # once — publishes the starter
./mvnw -Pdefault -pl uniauth-examples/webapp spring-boot:runThen open http://localhost:8080. The install step is required: -pl resolves the starter
from the local repository, not the reactor. See uniauth-examples/README.adoc for the rest.
| Sign in as | What it shows |
|---|---|
|
Internal store, |
|
Internal store, a local account beside the directory |
|
LDAP, with |
/ and /how-it-works are public; /dashboard is not, and is where a successful sign-in lands.
It states the outcome first — who was vouched for, by which provider, and whether an approval was
needed — then shows the provider panel with the answering port lit and what the principal carries.
Sign in as alice and then as bob to watch the same form resolve through different providers.
The sample also re-skins the chooser by dropping its own templates/uniauth/login.html,
which is the supported way to restyle it: an application’s templates come before the
starter’s on the classpath.
GET /login-
A chooser page — the shared credentials form when any form-based provider is on, plus one button per OAuth2/SAML registration.
GET /uniauth/providers-
The same information as JSON, for a single-page or mobile front end that wants to render its own chooser.
"Anyone with a Google account" and "anyone in the corporate directory" are both much larger sets than "people who should use this application". Turn approval on and a principal nobody has approved yet lands on a waiting page instead of the app.
uniauth:
approval:
enabled: true
require-for: [ LDAP, OAUTH2, SAML ] # default
pending-page: /pendingInternal accounts are ungated by default, on the grounds that writing one into configuration is already a deliberate act of approval.
The gate sits in authorization, not authentication. Failing the login instead would report "waiting for approval" as a credentials failure — misleading, and a small information leak, since it tells an attacker their guess was right.
UniAuth ships InMemoryApprovalStore so the flow works out of the box, and it is deliberately
unfit for production: state is lost on restart, so every approved user goes back to pending, and
nothing is shared between instances, so behind a load balancer a user’s standing depends on which
node they hit.
Supply your own ApprovalStore bean backed by whatever database you already have. Keeping
persistence behind that interface is what stops this starter from dragging Spring Data, a schema
and a migration story onto an application that only wanted a login page.
public interface ApprovalStore {
ApprovalStatus statusOf(ApprovalKey key);
ApprovalRecord recordPending(ApprovalKey key, PrincipalIdentity identity, AuthProviderType mechanism);
List<ApprovalRecord> pending();
Optional<ApprovalRecord> find(ApprovalKey key);
void decide(ApprovalKey key, ApprovalStatus outcome, String approver);
void remove(ApprovalKey key); // revoking: back to the waiting room, not locked out
}Keys are (provider, principal), never the principal alone: "alice" in the internal store and
"alice" at some OIDC provider are different people who happen to share a string.
The key is also unreadable — for an OIDC login the principal is a subject claim, a long number — so
PrincipalIdentity is captured at first sighting alongside it: display name, address, and whether
the provider says it verified that address. Approving on the key alone is deciding about a
stranger. Verification has three states and they are never collapsed into two, because an
unverified address is a claim somebody typed rather than something the provider stands behind.
Who may approve is your decision, not UniAuth’s. The library answers "is this principal
approved"; it has no opinion on who gets to say so. The sample restricts its queue to
ROLE_ADMIN with method security — see ApprovalController.
Spring Boot fills in well-known providers from CommonOAuth2Provider, matched on the
registration id, so registration.google needs only a client id and secret. That enum covers
Google, GitHub, Facebook, X and Okta — not Microsoft, and not Apple.
| Provider | OIDC | What to know |
|---|---|---|
Yes |
Works from a client id and secret alone. |
|
Okta / Auth0 / Keycloak |
Yes |
Generic. Set |
GitHub |
No |
Plain OAuth2, so no id_token, no claims, and nothing to support RP-initiated
logout. |
Microsoft Entra ID |
Yes |
Not in |
Apple |
Yes |
Not supported. The client secret must be a generated ES256 JWT, and Spring’s
|
The distinction that changes your code is OIDC or not, not the brand — AuthProvider.oidc()
carries it. Google gives you an OidcUser with claims; GitHub gives you a bare OAuth2User
assembled from a userinfo call. AuthProvider.brand() is for icons and button treatment only.
spring:
security:
oauth2:
client:
registration:
github:
client-id: ...
client-secret: ...
scope: [ read:user, user:email ] # user:email is required
uniauth:
oauth2:
github:
fetch-email: trueThis adds the second call to /user/emails that Spring’s default user service has no reason to
make, and merges the account’s primary verified address into the principal, marking it verified.
It prefers that over the email field on /user, which is the public profile address — whatever
the account chose to display, with nothing said about whether it is still theirs, and frequently
stale. Without the user:email scope the call is refused; the sign-in still succeeds, and whatever
/user gave survives unmarked, because unmarked is the truth about it.
spring:
security:
oauth2:
client:
registration:
microsoft:
client-id: ...
client-secret: ...
scope: [ openid, profile, email ]
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
microsoft:
authorization-uri: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
token-uri: https://login.microsoftonline.com/common/oauth2/v2.0/token
jwk-set-uri: https://login.microsoftonline.com/common/discovery/v2.0/keys
user-name-attribute: sub
uniauth:
oauth2:
microsoft:
multi-tenant: true # only for the common/organizations endpointsSingle-tenant needs no flag: put your tenant id in the URIs and set issuer-uri.
Multi-tenant does. OpenID Connect requires the iss claim to match discovery exactly, but Entra’s
common discovery document advertises an issuer containing a literal {tenantid} placeholder
while every id_token carries the caller’s real tenant, so the exact-match rule rejects everything.
The flag substitutes a check that the issuer is a GUID tenant on the same host.
|
Warning
|
That is a genuine loosening — with it on, any Entra tenant can sign in, not only
yours. Authorize on the tid claim if that is not what you want.
|
| Property | Default | Notes |
|---|---|---|
|
|
Master switch. Must be set to |
|
|
Path of the chooser page. |
|
|
Where to land after signing in. |
|
|
|
|
|
|
|
|
Hold unapproved principals at a waiting page. |
|
|
Which mechanisms are gated. |
|
|
Granted when an approver approves without naming roles. A federated principal has none of its own. |
|
|
Permitted automatically when approval is on. |
|
empty |
Extra ant-style paths served without a session. Use this for
public pages and static assets instead of declaring your own |
|
|
|
|
— |
Carries an algorithm prefix, e.g. |
|
|
|
|
— |
Full provider URL including the base DN. |
|
|
|
|
|
|
|
— |
Omit for an anonymous bind. |
|
|
Only takes effect if a |
|
|
Fetch the primary verified address from
|
|
|
Accept id_tokens from any Entra tenant rather
than one fixed issuer. Required for the |
|
|
Only takes effect if a |
|
|
Offers Basic to non-browser callers, over the form-based mechanisms. |
|
empty |
Where the challenge is offered; empty means everywhere. Scopes the challenge, not credential acceptance. |
Every bean is @ConditionalOnMissingBean, so declaring your own SecurityFilterChain,
AuthProviderRegistry or login controller replaces the starter’s without excluding the
auto-configuration.
Three hooks are worth knowing before you reach for a whole filter chain, because replacing it means giving up all the wiring:
uniauth.public-paths-
Opens up routes without touching the chain.
UniAuthAuthorizationCustomizerbean-
Contributes authorization rules to this chain, applied after the permitted paths and before the catch-all. Reach for it when the application’s rules are a different shape from "permitted or authenticated" — a role on an admin area, or a method-scoped rule.
AuthenticationEntryPointbean-
Declare one and the starter uses it instead of redirecting to the chooser. An API-first application returns 401 this way — see the
headlessexample.
scripts/dev-verify.sh # format + full verify (what CI runs)
scripts/dev-test.sh UniAuthLdapLoginTest # one test class
./mvnw spring-javaformat:apply # auto-format before committingJava 21, Spring Boot 4.1.x. Every verify runs four gates, all of which fail the build:
spring-javaformat (tabs, Spring conventions), Checkstyle, PMD, and JaCoCo at ≥80% line coverage.