Skip to content

Latest commit

 

History

53 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

UniAuth

Maven Central License Build Java 21+

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.

Why one filter chain

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 AuthenticationProvider chain — 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.

Quick start

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.

Using SAML

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.

Try it

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

webapp

Start here. Server-rendered pages, the provider chooser, and the approval queue.

headless

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:run

Then 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

alice / s3cret

Internal store, ROLE_USER + ROLE_ADMIN

breakglass / local-only

Internal store, a local account beside the directory

bob / bobspassword

LDAP, with ROLE_DEVELOPERS resolved from a group

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

What you get

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.

Approval: authenticating is not the same as being let in

"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: /pending

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

The store is yours to provide

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.

Provider notes

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

Google

Yes

Works from a client id and secret alone.

Okta / Auth0 / Keycloak

Yes

Generic. Set issuer-uri and discovery does the rest.

GitHub

No

Plain OAuth2, so no id_token, no claims, and nothing to support RP-initiated logout. email is null for private addresses — see uniauth.oauth2.github.fetch-email.

Microsoft Entra ID

Yes

Not in CommonOAuth2Provider; write the provider block by hand. Multi-tenant needs uniauth.oauth2.microsoft.multi-tenant.

Apple

Yes

Not supported. The client secret must be a generated ES256 JWT, and Spring’s ClientRegistration only accepts a static one.

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.

GitHub: getting an email address

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ...
            client-secret: ...
            scope: [ read:user, user:email ]   # user:email is required
uniauth:
  oauth2:
    github:
      fetch-email: true

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

Microsoft Entra ID

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 endpoints

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

Configuration reference

Property Default Notes

uniauth.enabled

false

Master switch. Must be set to true — the starter installs nothing until asked.

uniauth.login-page

/login

Path of the chooser page.

uniauth.default-success-url

/

Where to land after signing in.

uniauth.logout-success-url

/login?logout

uniauth.providers-endpoint

/uniauth/providers

uniauth.approval.enabled

false

Hold unapproved principals at a waiting page.

uniauth.approval.require-for

OAUTH2, SAML, LDAP

Which mechanisms are gated.

uniauth.approval.default-roles

[USER]

Granted when an approver approves without naming roles. A federated principal has none of its own.

uniauth.approval.pending-page

/pending

Permitted automatically when approval is on.

uniauth.public-paths

empty

Extra ant-style paths served without a session. Use this for public pages and static assets instead of declaring your own SecurityFilterChain, which would make the starter back off entirely.

uniauth.internal.enabled

false

uniauth.internal.users[].password

Carries an algorithm prefix, e.g. {noop} or {bcrypt}.

uniauth.ldap.enabled

false

uniauth.ldap.url

Full provider URL including the base DN.

uniauth.ldap.user-dn-patterns

uid={0},ou=people

uniauth.ldap.group-search-base

ou=groups

uniauth.ldap.manager-dn / .manager-password

Omit for an anonymous bind.

uniauth.oauth2.enabled

true

Only takes effect if a ClientRegistrationRepository exists.

uniauth.oauth2.github.fetch-email

false

Fetch the primary verified address from /user/emails when userinfo has none. Needs the user:email scope.

uniauth.oauth2.microsoft.multi-tenant

false

Accept id_tokens from any Entra tenant rather than one fixed issuer. Required for the common/organizations endpoints; loosens issuer validation.

uniauth.saml.enabled

true

Only takes effect if a RelyingPartyRegistrationRepository exists.

uniauth.http-basic.enabled

false

Offers Basic to non-browser callers, over the form-based mechanisms.

uniauth.http-basic.paths

empty

Where the challenge is offered; empty means everywhere. Scopes the challenge, not credential acceptance.

Overriding

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.

UniAuthAuthorizationCustomizer bean

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.

AuthenticationEntryPoint bean

Declare one and the starter uses it instead of redirecting to the chooser. An API-first application returns 401 this way — see the headless example.

Building

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 committing

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

Licence

Apache License 2.0.

About

Universal Spring Boot / Spring Security auth client — internal, OAuth2/OIDC, SAML 2.0 and LDAP behind one filter chain

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages