Approval gate

An identity provider will happily vouch for anyone who holds an account with it. That is its job, and it is not the same question as whether this application should admit them. The approval gate makes the second question explicit.

uniauth:
  approval:
    enabled: true
    require-for: [ LDAP, OAUTH2, SAML ]   (1)
    pending-page: /pending
1 Which mechanisms are held, and already the default. Internal accounts are left out because writing one into configuration is already an approval.

A held principal authenticates successfully and then lands on the pending page until an administrator decides. Approving admits them on their next request — no second sign-in.

1. Three decisions worth not re-litigating

It sits in authorization, not authentication. ApprovalAuthorizationManager replaces .authenticated() in the chain. Failing the login instead would report "pending" as a credentials failure, which is both misleading and a disclosure: it confirms a correct guess to whoever is guessing.

First sighting is recorded in the manager, not a success handler. Four mechanisms would mean four success handlers; every request passes authorization exactly once.

Keys are (provider, principal). Approving alice from the internal store must not admit alice from Google.

2. Who is waiting

The key is stable and unreadable — for an OIDC login it is the sub claim, a long number. Approving on that alone is deciding about a stranger, so PrincipalIdentity is captured at first sighting and carried alongside:

displayName

a human name, or absent

email

the address claimed, or absent

emailVerified

whether the provider says it verified that address — or absent, when it says nothing

subject

the stable identifier the key is built from

Three states for verification, never collapsed into two. An unverified address is a claim somebody typed, not something the provider stands behind; treating it as identity means trusting whoever asserted it rather than the provider. "The provider said nothing" is a third answer again, and pretending otherwise would make the queue look more informative than it is.

It is captured at first sighting rather than read live because the queue has to render for a principal who is no longer signed in.

3. Approving grants roles

Approval is not only "may this principal in". A federated sign-in arrives with OIDC_USER and a SCOPE_* per scope and no ROLE_ at all, so without this an approved user clears the gate, reaches the application, and is refused by every hasRole(…​) rule in it — while an internal account has had roles all along, from configuration. The two mechanisms were not equivalent in what they could express, and the federated one needs it more.

So the decision carries them:

void decide(ApprovalKey key, ApprovalStatus outcome, String approver, List<String> roles);
uniauth:
  approval:
    default-roles: [ USER ]   # granted when an approver approves without naming any

The ROLE_ prefix is added if absent, so an approver types ADMIN and hasRole("ADMIN") matches. They are granted on the request after the decision, which is the same promise the gate itself makes — approval takes effect without a second sign-in.

This is why a GrantedAuthoritiesMapper bean cannot do the job. It receives the authorities and not the principal, and roles are stored per principal, so it has nothing to look them up by. The authorities are attached by a filter that has both.

The authentication is rebuilt to carry them, keeping its concrete type: that type is how MechanismResolver knows which provider answered, so flattening every principal into a UsernamePasswordAuthenticationToken would quietly break the approval key on the next request. A type UniAuth does not recognise is left exactly as it was.

4. The store

ApprovalStore is an SPI, and the gate is the only stateful thing in the library. That is deliberate: owning a table would force Spring Data, a schema and migrations onto every consumer.

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);
}
InMemoryApprovalStore is the default and is not production-fit. It empties on restart, taking every approval with it, and it is per-instance — two replicas do not agree. Supply your own implementation for anything real.

5. Two things that bite if changed carelessly

  • The pending page is added to the permitted matchers. Without that, the redirect loops.

  • PendingApprovalAccessDeniedHandler only redirects requests the manager flagged, so a genuine 403 still looks like one. A denied principal gets 403, not the waiting page.