Skip to main content

REST Authenticator — Resource-Level Authentication

The RestAuthenticator SPI provides an ergonomic, in-resource way to resolve a request's Principal and roles for @Rest resources — so roleGuard, RoleBasedRestGuard, @Auth Principal injection, and req.isUserInRole(...) all "just work" without container-managed security or a servlet-layer filter.

See the guide: Secure an endpoint with JWT and OAuth

It complements the AuthN Filter Framework: that framework authenticates at the servlet-container layer (before Juneau routing); RestAuthenticator authenticates at the resource layer (per matched operation). Both reuse the same underlying auth implementations (SAML, OAuth, JWT, API-key, bearer-token), so a single bean can serve either layer.

Resource layer vs. servlet-filter layer​

AspectServlet-filter layer (AuthFilterChain)Resource layer (RestAuthenticator)
Where it runsA jakarta.servlet.Filter, before the RestServlet is reached.A dedicated framework step inside Juneau dispatch, after the operation is matched and before guards.
Registration@Bean AuthFilterChain auto-mounted by JettyServerComponent.Resource bean / @Rest(authenticator=) / @Bean method / RestServlet.createAuthenticator(...).
ScopeURL patterns across the whole servlet.Per @Rest resource, inherited down the child-resource tree.
Unmatched (404) pathsFilter still runs.Authenticator runs only for matched operations.
Underlying contractAuthenticator (AuthFilter, AuthFilterChain).RestAuthenticator (adapts any Authenticator).

Both layers ultimately produce the same thing — a resolved Principal + role set surfaced through getUserPrincipal() / isUserInRole(String) / getRemoteUser() and the RestServerConstants.PRINCIPAL_ATTR request attribute — so downstream guards and @Auth resolution don't care which layer authenticated the request.

The Authenticator contract​

The lowest-level contract is shared by both layers:

@FunctionalInterface
public interface Authenticator {
Optional<AuthResult> authenticate(HttpServletRequest req) throws AuthenticationException;
}

The same three-state return contract as AuthFilter:

StateMeaning
Optional.empty()Does not apply — no recognizable credentials.
Optional.of(AuthResult)Authentication succeeded.
throw AuthenticationExceptionCredentials present but invalid (→ 401).

AuthFilter and AuthFilterChain both implement Authenticator, so every existing filter (BearerTokenAuthFilter, ApiKeyAuthFilter, SamlAuthFilter, OAuthFilter, OidcSessionAuthFilter, custom impls) is reusable verbatim at the resource layer.

The RestAuthenticator SPI​

RestAuthenticator is the resource-level front door. It operates on RestRequest (which is-a HttpServletRequest), so adapting an existing Authenticator is a one-liner:

public abstract class RestAuthenticator {

// empty = anonymous/passthrough, present = success, throw = 401.
public abstract Optional<AuthResult> authenticate(RestRequest req) throws AuthenticationException;

// Adapts any Authenticator (AuthFilter / AuthFilterChain) into a RestAuthenticator.
public static RestAuthenticator of(Authenticator a) { ... }
}

A custom authenticator can wrap a reusable filter bean directly:

// Reuse the SAML filter bean at the resource layer.
public class SamlRestAuthenticator extends RestAuthenticator {
private final SamlAuthFilter filter;

public SamlRestAuthenticator(SamlAuthFilter filter) { // constructor injection
this.filter = filter;
}

@Override
public Optional<AuthResult> authenticate(RestRequest req) {
return filter.authenticate(req);
}
}

There is intentionally no separate roles-only authenticator subclass — that need is folded into the AuthResult merge modes below.

AuthResult merge modes​

AuthResult carries an immutable merge mode that controls how its Principal + roles combine with results accumulated earlier in the chain:

FactoryModePrincipalEffect
AuthResult.of(principal, roles...)ADDrequiredUnion these roles into the accumulated set; first non-null principal wins.
AuthResult.ofRoles(roles...)ADDnullRoles-only augmentation — keep the inherited/existing principal, just add roles.
AuthResult.replacing(principal, roles...)REPLACErequiredDiscard everything accumulated so far; this principal + roles win outright.
// ADD (default): contribute identity + roles.
AuthResult.of(principal, "admin", "user");

// ADD, roles-only: keep whoever authenticated upstream, just add a role.
AuthResult.ofRoles("reader");

// REPLACE: swap identity entirely (e.g. JWT instead of an inherited SAML identity).
AuthResult.replacing(jwtPrincipal, "admin");

The existing AuthResult.of(...) factories are unchanged and default to ADD, so AuthFilterChain's long-standing union semantics are preserved.

Registration & dependency injection​

A resource's RestAuthenticator is resolved with the following precedence (highest first), mirroring how guards / converters / encoders resolve and memoized per RestContext:

  1. Registered bean — bs.getBean(RestAuthenticator.class).
  2. @Bean factory method on the resource.
  3. @Rest(authenticator = MyAuthenticator.class) — instantiated via the bean store (constructor injection).
  4. Overridable method — protected RestAuthenticator createAuthenticator(BeanStore bs) on RestServlet (returns null by default).

All four paths run through the resource's bean store, and because child bean stores delegate to the parent, both injection scenarios below work with no special handling.

(a) Inject an Authenticator into a RestAuthenticator​

@Bean
public SamlAuthFilter samlFilter(SamlAssertionValidator v) {
return SamlAuthFilter.create().validator(v).build();
}

public class SamlRestAuthenticator extends RestAuthenticator {
private final SamlAuthFilter filter;
public SamlRestAuthenticator(SamlAuthFilter filter) { this.filter = filter; } // ctor injection

@Override
public Optional<AuthResult> authenticate(RestRequest req) { return filter.authenticate(req); }
}

@Rest(authenticator = SamlRestAuthenticator.class)
public class SecuredResource extends BasicRestServlet { ... }

(b) Inject a RestAuthenticator into a REST resource​

@Rest(children = { ... })
public class RootResource extends BasicRestServlet {

@Bean
public RestAuthenticator authenticator(AuthFilterChain chain) { // chain injected
return RestAuthenticator.of(chain);
}
}

A RestAuthenticator (or Authenticator) bean registered on the root or any ancestor is visible to every descendant resource's bean store through the parent-delegating chain.

To declaratively unset an inherited annotation value, the @Rest(authenticator=...) attribute defaults to the sentinel RestAuthenticator.Null ("not specified").

Inheritance & folding across child resources​

This is a deliberate divergence from guards (which are child-isolated). Authenticators inherit down the child-resource tree. At request time, for a target resource D, the framework:

  1. Walks the child-resource chain root → … → D, collecting each node's resolved authenticator (skipping nodes that declared none). A node carrying noInherit={"authenticator"} truncates the walk above it — the subtree opts out and becomes anonymous.
  2. Runs the collected authenticators in order (root first), folding each Optional<AuthResult>:
    • empty → skip (doesn't apply);
    • ADD → keep the current principal if one is already set (else adopt this result's principal, which may be null), and union roles;
    • REPLACE → reset the accumulator to this result's principal + roles;
    • throw AuthenticationException → record the failure.
  3. After folding: if a principal/roles were resolved, apply them to the request. If nothing succeeded but at least one threw → aggregated 401. If everything was empty → anonymous passthrough.

Root covers all​

A single authenticator on the root resource covers every descendant — child resources that declare nothing simply inherit it:

@Rest(children = { ChildResource.class })
public class RootResource extends BasicRestServlet {
@Bean
public RestAuthenticator authenticator(AuthFilterChain chain) {
return RestAuthenticator.of(chain); // covers RootResource + ChildResource + ...
}
}

@Rest(path = "/child")
public class ChildResource extends BasicRestServlet {
@RestGet(path = "/whoami", roleGuard = "admin")
public String whoami(@Auth Principal p) { return p.getName(); } // identity inherited from root
}

Descendant augments (adds roles)​

A child can contribute extra roles while keeping the inherited identity by returning an ADD result with a null principal:

@Rest(path = "/childAdd", authenticator = AddRoleAuthenticator.class)
public class ChildAddResource extends BasicRestServlet { ... }

public class AddRoleAuthenticator extends RestAuthenticator {
@Override
public Optional<AuthResult> authenticate(RestRequest req) {
return Optional.of(AuthResult.ofRoles("extra")); // keep root principal, add "extra"
}
}

Descendant replaces (swaps identity)​

A child can swap the inherited identity entirely (the "JWT instead of SAML" case) with a REPLACE result:

@Rest(path = "/childReplace", authenticator = JwtReplaceAuthenticator.class)
public class ChildReplaceResource extends BasicRestServlet { ... }

public class JwtReplaceAuthenticator extends RestAuthenticator {
@Override
public Optional<AuthResult> authenticate(RestRequest req) {
var jwtPrincipal = verifyJwt(req); // throws AuthenticationException on bad token
return Optional.of(AuthResult.replacing(jwtPrincipal, "admin")); // discard inherited identity
}
}

Subtree opt-out with noInherit​

A descendant can cut off inherited authenticators for its subtree — useful for a public area under an otherwise-secured root:

@Rest(path = "/public", noInherit = { "authenticator" })
public class PublicResource extends BasicRestServlet {
@RestGet(path = "/ping") // no inherited auth; no roleGuard → anonymous passthrough
public String ping() { return "pong"; }
}

Request-time wiring​

The folded (principal, roles) is stored on the RestRequest, which overrides getUserPrincipal() / isUserInRole(String) / getRemoteUser() to consult the stored result (falling back to the wrapped request when the stored value is null), and stashes the principal under RestServerConstants.PRINCIPAL_ATTR so @Auth Principal injection finds it. This mirrors exactly what AuthenticatedRequestWrapper overrides at the filter layer, so existing code works unchanged:

@Rest(authenticator = MyAuthenticator.class)
public class ApiResource extends BasicRestServlet {

@RestGet(path = "/me", roleGuard = "admin") // roleGuard sees resolved roles
public String me(@Auth Principal p) { // @Auth sees resolved principal
return p.getName();
}

@RestGet(path = "/check")
public boolean check(RestRequest req) {
return req.isUserInRole("admin"); // isUserInRole works unchanged
}
}

The authenticator fold runs once per request, before guards (so roleGuard sees resolved roles) and before op-argument resolution (so @Auth sees the principal). Concretely it is a dedicated framework step invoked immediately before @RestPreCall hooks, giving this deterministic order:

  1. find operation / create the op session;
  2. authenticate fold → annotate the RestRequest;
  3. @RestPreCall hooks (also see the resolved principal/roles);
  4. guard loop (roleGuard sees roles) → op invocation (@Auth sees principal);
  5. @RestPostCall.

Because the fold runs per matched operation, unmatched (404) paths are never authenticated. Already-authenticated requests (container security or a servlet-layer AuthFilterChain) are augmented by default — an ADD result keeps the existing principal and unions roles; a REPLACE result overrides.

See also​