spring-authentication-server

Logo

Writing an authentication module

License


An authentication module is the part of the server that actually authenticates the user. It shows a login screen, talks to a BankID server, verifies a client certificate, or delegates to another Identity Provider. Everything around it, the protocol endpoints, the request parsing, the assertion and token building, is the library’s job.

User authentication is implemented once and serves both SAML and OpenID Connect. A module is therefore written against protocol-neutral types only, all of them in authn-server-core. Write the module once and it works in an Identity Provider, in an OpenID Provider, and in a server that is both, with single sign-on shared between the two.

A module has one job: take what the requester asked for, authenticate the user, and say who the user is and how it was done.

Source links in this guide point to the main branch of the spring-authentication-server repository.

Writing the provider

A module plugs into the server as a UserAuthenticationProvider, which is a Spring Security AuthenticationProvider. Extend AbstractUserAuthenticationProvider and implement three methods:

public class MyAuthenticationProvider extends AbstractUserAuthenticationProvider {

  @Override
  public @NonNull String getName() {
    return "my-provider";
  }

  @Override
  public @NonNull List<String> getSupportedAuthnContextUris() {
    return List.of(LOA3, LOA4);
  }

  @Override
  protected @NonNull Authentication authenticate(final @NonNull UserAuthenticationInputToken token,
      final @NonNull List<String> authnContextUris) {
    ...
    return new UserAuthentication(user);
  }
}

The base class does everything around the authentication, in this order:

  1. The requested authentication contexts are filtered against the ones the provider supports. If none remain, the provider does not handle the request at all and the next provider is asked. When no provider handles it, the requester gets NO_AUTHN_CONTEXT.

  2. Single sign-on is decided. If a previous authentication may be reused, it is returned and authenticate is never called.

  3. If the request required that the user was not interacted with, and single sign-on was refused, the request fails with PASSIVE_NOT_POSSIBLE.

  4. Otherwise authenticate is called with the contexts that remain, in the requester’s order of preference.

So authenticate is called only when the provider can serve the request and the user really has to authenticate. It picks a context from authnContextUris, authenticates at that level, and returns the result.

Whatever the result comes from, single sign-on or a new authentication, the base class then gives it the requirements and the protocol data of the request, records its use, and runs the post-authentication processing. A module does not do any of that itself.

Several providers may be installed, each supporting its own authentication contexts. A provider is asked only about requests it can serve, so a module never has to check whether it is the right one.

What the provider declares

Besides its authentication contexts, a provider may declare what it offers. The server publishes the declarations in the SAML metadata and in the OpenID Connect discovery document. Each has a default method on UserAuthenticationProvider that returns an empty list, so a provider overrides only what applies to it.

Method What it declares Used by
getSupportedAuthnContextUris() The authentication contexts the provider can deliver. Required. SAML: the assurance certification attribute. OpenID Connect: acr_values_supported.
getEntityCategories() SAML entity categories. SAML: the entity category attribute.
getSupportedAttributes() The generic attributes the provider can deliver, by identifier, such as attribute.personal-identity-number. OpenID Connect: the supported claims, and the scopes when none are declared.
getSupportedScopes() The OpenID Connect scopes the provider offers. OpenID Connect: the offered scopes.
@Override
public @NonNull List<String> getSupportedAttributes() {
  return List.of(AttributeIdentifiers.PERSONAL_IDENTITY_NUMBER, AttributeIdentifiers.GIVEN_NAME,
      AttributeIdentifiers.SURNAME, AttributeIdentifiers.DISPLAY_NAME, AttributeIdentifiers.DATE_OF_BIRTH);
}

A provider that declares its attributes normally does not need to declare scopes, since the OpenID Provider derives them from the claims. Declare scopes when the derived set is wrong for the provider, for example a scope whose essential claims the provider delivers only some of. How scopes and claims are worked out is described in The OpenID Provider.

The declarations only tell what the server offers. They do not limit what the provider releases: a provider still returns everything it knows about the user, see The authenticated user. See Configuration for the SAML metadata.

A module that cannot authenticate the user inside this call, because it needs pages of its own, is written slightly differently, see Modules with pages of their own.

What the module receives

AuthenticationRequirements is what the requester asked for, with the differences between the two protocols already resolved. The module does not need to know which protocol was used. The UserAuthenticationInputToken adds who is asking, the identifier of the request, the previous authentication if there is one, and the protocol data of the request.

This is the mapping, in case you need to reason about a request you are looking at:

What the module sees SAML OpenID Connect
isForceAuthn() ForceAuthn prompt=login
getMaxAuthnAge() - max_age
isPassiveAuthn() IsPassive prompt=none
isConsentRequired() - prompt=consent
getRequestedAttributes() RequestedAttribute, from the request or from metadata the claims parameter
getAuthnContextRequirements() AuthnContextClassRef in RequestedAuthnContext acr_values
getRequestedAuthnProviders() IDPList under Scoping authnProvider
getOriginalRequesters() RequesterID under Scoping originalClientId, originalClientToken
getUserMessage() the UserMessage extension userMessage
getSignMessage() the SignMessage extension sign_message in signRequest

Whether to authenticate the user again

isForceAuthn() means the user must be authenticated again even if an earlier authentication could have been reused. A maximum authentication age of zero means the same thing, since that is how OpenID Connect defines max_age=0, so the module only has to look at isForceAuthn().

isPassiveAuthn() means the module must not interact with the user at all. The base class already fails such a request when single sign-on was refused, so authenticate is reached for a passive request only if the module can authenticate the user without asking anything, for instance from a client certificate.

Which authentication contexts are acceptable

getAuthnContextRequirements() holds the authentication context URIs the requester accepts, in its order of preference, because that is how acr_values is defined. Sweden Connect uses the same URIs in SAML and in OpenID Connect, so this is one list for both protocols.

The contexts are either required or voluntary, see isVoluntaryAuthnContexts(). Required contexts, which SAML always sends and OpenID Connect sends for an essential acr, must be met: if no provider supports any of them, the request fails with NO_AUTHN_CONTEXT. Voluntary contexts, such as OpenID Connect acr_values, are wishes: before the providers are asked, the contexts that no provider supports are removed, and if none is left, the providers are asked as if no context had been requested.

A module does not read this list. It gets the contexts that are left after filtering, in the same order, as the second argument to authenticate. It picks the one it delivers and reports it as part of the authenticated user.

Which attributes are requested

getRequestedAttributes() holds the requested attributes in the generic form described in Attributes. A requested attribute may be essential, and it may carry the values the requester will accept.

There is no separate notion of principal selection. When a SAML requester sends a PrincipalSelection extension, or an OpenID Connect client sends a claims request with a value, the module sees a requested attribute that carries that value. A hint that the user is 197705232382 therefore arrives as a requested attribute.personal-identity-number with that value, and a module that can prefill a field should look for exactly that.

When the user does not match the requested values

A module does not have to check the requested values itself. After a new authentication, the library compares every requested attribute that carries values with the user the module returned. The attribute matches when any requested value equals any of the user’s values for it.

The user The values are essential The values are voluntary
Has a matching value The request proceeds. The request proceeds.
Has the attribute, but no matching value The request fails with UNKNOWN_PRINCIPAL, and the session authentication is removed. The request proceeds with the user’s actual values, and the mismatch is logged at INFO.
Does not have the attribute The request proceeds, logged at INFO. The request proceeds, logged at INFO.

A missing attribute never fails the request, following OpenID Connect Core, Section 5.5.1. The logs name the attribute, never the requested or the actual values. A reused authentication is not checked here; the rule for single sign-on is described under The rules that always apply.

Whether the values are essential is isRequestedValuesEssential() of the requested attribute. It is decided by the request entry that carries the values, not by other requests for the same attribute, see Generic requested attribute. A PrincipalSelection value is therefore voluntary even when the metadata of the Service Provider requires the attribute.

A module returns the attributes it actually established. It is not obliged to return everything that was asked for, and it may return more.

Where to authenticate, and who is really asking

getRequestedAuthnProviders() is the requester asking for a particular authentication service or mechanism. A server that offers several should honour it when it can.

getOriginalRequesters() matters when the requester is itself a proxy and is passing on who originally asked. Each OriginalRequester is an identifier and an optional token. SAML allows any number of identifiers and carries no token; OpenID Connect allows one identifier and may add a token whose format no specification defines. Use it for what you display to the user, never for an access decision, since it is the proxy’s claim about a third party.

The user message

getUserMessage() is a message the requester wants shown together with the authentication, for example “You are about to sign in to the Tax Agency”. If the module shows a screen, it should show this message.

A GenericUserMessage holds the message in one or more languages, one LocalizedMessage per language, and all of them share one MIME type. Ask for the language you are rendering in:

LocalizedMessage message = requirements.getUserMessage().getMessage("sv-SE");
String text = message != null ? message.getText() : null;

getMessage prefers an exact language match, then a message in the same primary language, so sv-SE finds an sv message, and last the message that is not tied to a language. It returns null when there is nothing to show in that language, which is a real possibility: SAML requires a language on every message, and only OpenID Connect allows one message without one.

Both specifications carry the message as Base64 of UTF-8, so that is how it is held. getText() decodes it. Render it according to getMimeType(), which is text/plain, text/markdown or text/html; see MessageMimeType. A module that cannot render Markdown or HTML should not fall back to showing the raw markup.

The sign message

getSignMessage() is different in kind. It is not information, it is what the user is approving, and it belongs to a signature operation. A GenericSignMessage holds the same messages and MIME type as a user message, and adds two things:

A module that displays a sign message must record that it did, and in which language, on the authenticated user. The post-authentication processing checks that, and a result where the message was not displayed fails with SIGN_MESSAGE_NOT_DISPLAYED.

An encrypted SAML sign message is decrypted before the module sees it, and the SAML DisplayEntity has already been checked against the server’s own entityID. Neither reaches the module.

What SAML adds

A protocol may ask for more than the generic model holds, so AuthenticationRequirements can be subtyped. A module written against the generic type works everywhere; a module that only ever runs in an Identity Provider may look for SamlAuthenticationRequirements, which adds the entity categories the Service Provider declares in its metadata and the SADRequest extension that a signature service sends.

if (requirements instanceof final SamlAuthenticationRequirements saml) {
  ...
}

The same goes for the protocol data of the request, which the generic layer carries without looking at it. A module that needs the request itself asks for it and casts it to the type its protocol module uses:

Object requestData = token.getProtocolRequestData();

What the module returns

The authenticated user

AuthenticatedUser is what the module established about the user. It is a Spring Security UserDetails.

AuthenticatedUser user = new AuthenticatedUser(
    List.of(
        GenericAttribute.of(AttributeIdentifiers.PERSONAL_IDENTITY_NUMBER, "197705232382"),
        GenericAttribute.of(AttributeIdentifiers.GIVEN_NAME, "Agda"),
        GenericAttribute.of(AttributeIdentifiers.SURNAME, "Andersson")),
    AttributeIdentifiers.PERSONAL_IDENTITY_NUMBER,
    "http://id.elegnamnden.se/loa/1.0/loa3",
    Instant.now(),
    "192.168.1.14");

The five things the module must supply:

The constructor rejects an empty set of attributes, a primary attribute that is not among them, and a missing authentication context, instant or client address. Getting one of these wrong is a programming error, not a runtime condition, so it fails immediately rather than producing a half-valid result.

A module that delegates the authentication to another service does not record the authenticating authority separately. Set the attribute.authentication-provider attribute and the SAML layer turns it into <AuthenticatingAuthority>.

If the module displayed a sign message, say so and say in which language, because an OpenID Connect sign message may have been offered in several and only the one shown matters:

user.setSignMessageDisplayed(true, "sv");

The authentication result

UserAuthentication is a Spring Security Authentication wrapping the user. It is what the SAML layer turns into an assertion and the OpenID Connect layer into tokens, and it is what is kept in the session for single sign-on.

UserAuthentication authentication = new UserAuthentication(user);

The user is the principal, and getName() returns the user name. For most modules that is the whole of it: create the object and return it.

Because the object lives in the session, everything it holds must survive Java serialization. That includes the attribute values a module puts on the user, so a module that invents its own attribute value type must make it Serializable.

When a result must not be reused for single sign-on

By default a result may be reused, and isReuseForSso() says so. A module turns that off when the authentication was tied to this one request:

authentication.setReuseForSso(false);

One case is decided for the module: when a sign message was displayed, the result is never reusable and that cannot be undone. Section 2.4 of the Sweden Connect OpenID Connect profile forbids saving a signature approval for single sign-on, and the same holds for a SAML signature service. Recording the displayed sign message on the user is enough; setReuseForSso(true) afterwards has no effect.

Turning reuse off is a decision that the result must not be saved. Leaving it on is not a decision that it will be: the server may still choose not to save it.

Single sign-on

Single sign-on is what makes a previous authentication answer a new request, and it works across the two protocols: a user who signed in at a SAML Service Provider can be let straight through at an OpenID Connect client.

The rules that always apply

These are checked before any policy or voter, and no configuration turns them off:

The policy

Everything else is an SsoPolicy. The default policy allows single sign-on for 60 minutes, which is the Sweden Connect maximum, and only for the requester the authentication was made for. Two things are configurable:

What the policy never relaxes, beyond the rules above, is the authentication context: a previous authentication is reused only when it was made under a context that is acceptable for the new request.

SsoPolicy.defaultPolicy();      // 60 minutes, same requester
SsoPolicy.none();               // never
SsoPolicy.forSessionLifetime(); // as long as the session lives, any requester

The policy is set as a server default, a protocol may have a policy of its own, and a provider may override both. The order is: provider, protocol, server default. For each request, the policy of the requester’s protocol is used:

provider.setServerSsoPolicy(SsoPolicy.none());                                     // set by the configuration
provider.setServerSsoPolicy(AuthenticationProtocol.SAML, SsoPolicy.defaultPolicy()); // set by the configuration
provider.setSsoPolicy(myOwnPolicy);                                                // this provider only

The server default and the protocol policies are set from the authn-server.sso.* and authn-server.saml.sso.* properties, see Configuration.

Voters

The decision is made by an ordered list of SsoVoters. One denial ends it, and at least one voter has to allow it, so a request where every voter abstains gets no single sign-on. Three voters are installed: one applies the policy, one requires the same authentication context, and one refuses when the requester asks for another set of attributes than the original authentication was made for.

An application adds voters of its own to refuse single sign-on in cases the library knows nothing about:

provider.getSsoVoters().add((previous, requirements, requester, contexts) ->
    previous.getAuthenticatedUser().getClientIpAddress().equals(currentIpAddress())
        ? SsoDecision.abstain()
        : SsoDecision.deny(SsoDenialReason.NOT_ALLOWED));

Voters that apply to every provider are better added to the server configuration, either to the shared list, for all protocols, or to the list of one protocol. They are asked after the provider’s own voters, see Producers, voters and processors.

A refusal always states its reason, an SsoDenialReason. That matters for a passive request, see below.

What happens to the session authentication

The session holds one authentication for the whole server, the one that a later request may reuse. What happens to it depends on how a request ends:

How use of the result is tracked

The library records every time a result is used, one record per use, in an AuthenticationUsageTrack. A record holds the protocol, the requester, the request identifier where the protocol has one, the instant and the attributes that requester asked for. The first record is the original authentication, and isSsoApplied() tells whether there were more.

A module does not write these records, and it should not read them either. They are what the voters decide from: how long ago the user was authenticated, for which requester, and whether a different set of attributes is being asked for the second time.

The requirements and the protocol data of the request are cleared from the result before it is saved, so a module must not expect them to still be there when a result comes back from the session.

Errors

A module reports a failure by throwing an AuthenticationErrorException carrying an AuthenticationError. The protocol module turns it into a SAML Status or an OpenID Connect error response, so a module never writes protocol-specific error handling.

throw new AuthenticationErrorException(AuthenticationError.CANCEL);
throw new AuthenticationErrorException(AuthenticationError.AUTHN_FAILED, "The BankID server refused the order");

The second argument is a description for logs. In OpenID Connect it becomes the error_description, which the Swedish OpenID Connect Profile, Section 2.3, asks to be something the Relying Party can put in its application logs. It is never shown to the user, so write it for an operator, not for an end user.

These are the errors and how they reach the requester:

Error SAML status OpenID Connect error
AUTHN_FAILED Responder / AuthnFailed access_denied
CANCEL Responder / .../status/1.0/cancel access_denied
FRAUD Responder / .../status/1.0/fraud access_denied
POSSIBLE_FRAUD Responder / .../status/1.0/possibleFraud access_denied
SIGN_MESSAGE_NOT_DISPLAYED Responder / AuthnFailed access_denied
UNKNOWN_PRINCIPAL Requester / UnknownPrincipal access_denied
PASSIVE_NOT_POSSIBLE Requester / NoPassive login_required or interaction_required
NO_AUTHN_CONTEXT Requester / NoAuthnContext unmet_authentication_requirements
NOT_AUTHORIZED Responder / RequestDenied unauthorized_client

The three Sweden Connect status codes are http://id.elegnamnden.se/status/1.0/ followed by cancel, fraud or possibleFraud. They are second-level codes under Responder, as the Deployment Profile, Section 6.4, requires. A server that detects fraud must not issue an assertion.

CANCEL, FRAUD and POSSIBLE_FRAUD are the errors a module raises that are not plain failures. Use CANCEL when the user chose to stop, and the fraud codes when a security check alerted, not when the authentication merely failed.

The mappings live in the protocol modules, SamlErrorStatus and OidcErrorMapping, and a module does not call them.

The passive case

PASSIVE_NOT_POSSIBLE is the one error whose OpenID Connect code depends on why it happened, so the exception carries the SsoDenialReason that refused single sign-on. A request that could have been answered except that the requester asks for another set of attributes gets interaction_required, as the OpenID Connect Profile for Sweden Connect, Section 2.2.1, requires. Every other reason gets login_required. SAML has one code for all of them, NoPassive.

The base class raises this error, so a module does not.

Errors that cannot be reported

Some failures make it impossible to send the user back to the requester at all, for instance when the request cannot be tied to a session. The user is shown an error page instead. Those are an UnrecoverableError, thrown as an UnrecoverableErrorException.

The core defines the two that do not depend on a protocol, in CommonUnrecoverableError: an internal error and an invalid session. Each protocol module adds its own as its request processing is built, which is why UnrecoverableError is an interface and not an enum.

The error page is the application’s own, chosen through Spring Boot’s error handling: a view for the /error dispatch that finds the UnrecoverableErrorException in the jakarta.servlet.error.exception request attribute and shows the message that its message code resolves to. An error may give the HTTP status of the page, getHttpStatus(), which the OpenID Provider uses for the errors of the authorization endpoint, see Failures; the default is 500. The Sweden Connect reference shows how, with UnrecoverableErrorViewResolver, and with a filter that makes the page the answer also when the request does not accept HTML, since Spring Boot answers such a request with JSON.

Modules with pages of their own

Most modules cannot authenticate the user inside the call to authenticate. They need to show a login screen, poll a BankID server, or walk the user through a few steps. Such a module is sent the user instead: the server redirects to a controller of the module’s own, the controller authenticates the user, and the flow resumes afterwards.

The module writes two things, a provider and a controller, and neither of them has to know which protocol the requester used. A SAML login and an OpenID Connect login reach the same controller and return to the same resume path.

The provider

Extend AbstractUserRedirectAuthenticationProvider instead of AbstractUserAuthenticationProvider. It takes the two paths, the one the user is sent to and the one the user comes back to:

public class MyAuthenticationProvider extends AbstractUserRedirectAuthenticationProvider {

  public MyAuthenticationProvider() {
    super("/authn/login", "/authn/resume");
  }

  @Override
  public boolean supportsUserAuthenticationToken(final @Nullable Authentication authentication) {
    return authentication instanceof UserAuthentication;
  }

  @Override
  protected @NonNull UserAuthentication createUserAuthentication(final @NonNull ResumedAuthenticationToken token) {
    return (UserAuthentication) token.getAuthnToken();
  }
}

Everything the base class does around an authentication still happens, and it happens before the user is sent anywhere: the authentication contexts are filtered, single sign-on is decided, and a passive request that cannot be answered fails. Only when the user really has to authenticate is a RedirectForAuthenticationToken returned instead of a result. The server’s filter chain serves the two paths: the authentication path is open to everyone, and the resume path is where the flow continues, in the protocol that the requester used. See The SAML Identity Provider.

:raised_hand: Only these two exact paths are covered by the server’s filter chain. Any other page of the module, such as the one a login form posts to, is not, and the application must secure it in its own security configuration. For example, permit the module’s paths in the application’s filter chain:

@Bean
@Order(2)
SecurityFilterChain applicationSecurityFilterChain(final HttpSecurity http) throws Exception {
  http.authorizeHttpRequests(authorize -> authorize
      .requestMatchers("/authn/**").permitAll()
      .anyRequest().denyAll());
  return http.build();
}

createUserAuthentication is the counterpart of authenticate. It is called when the user comes back, and it turns what the controller delivered into the result. A controller that already delivers a UserAuthentication has nothing to do but cast; one that delivers a token of its own builds the result from it. The result then runs through the same post-authentication processing as a direct authentication, so a module does not do anything differently because the authentication happened elsewhere.

supportsUserAuthenticationToken says whether the provider can use what the controller delivered. It is how the right provider is found when several are installed.

The identifier of the authentication

Each authentication gets an unguessable identifier when the redirect starts. It travels to the controller and back as the authnId request parameter, and it is what makes several authentications in the same session independent of each other: an OpenID Connect login in one browser tab and a SAML login in another do not disturb each other, and each resumes with its own input and its own result.

An authentication is removed once it has been resumed, and one that is never resumed is removed when it is older than the maximum age, 30 minutes by default. A request to the resume path that carries no identifier, or an identifier that is unknown, expired or already resumed, is the invalid session error, and the user is shown an error page.

The controller

Extend AbstractAuthenticationController, which gives the controller what it needs and takes care of getting the user back into the flow:

@Controller
@RequestMapping("/authn")
public class MyAuthenticationController extends AbstractAuthenticationController<MyAuthenticationProvider> {

  private final MyAuthenticationProvider provider;

  public MyAuthenticationController(final MyAuthenticationProvider provider) {
    this.provider = provider;
  }

  @GetMapping("/login")
  public ModelAndView login(final HttpServletRequest request) {
    final RedirectForAuthenticationToken token = this.getInputToken(request);
    final ModelAndView view = new ModelAndView("login");
    view.addObject("authnId", token.getAuthnId());
    view.addObject("userMessage", token.getAuthnInputToken().getAuthnRequirements().getUserMessage());
    return view;
  }

  @PostMapping("/complete")
  public ModelAndView complete(final HttpServletRequest request) {
    ...
    return this.complete(request, new UserAuthentication(user));
  }

  @PostMapping("/cancel")
  public ModelAndView cancelled(final HttpServletRequest request) {
    return this.cancel(request);
  }

  @Override
  protected @NonNull MyAuthenticationProvider getProvider() {
    return this.provider;
  }
}

getInputToken gives what this authentication was asked to do, the same UserAuthenticationInputToken the provider was given, plus the authentication contexts that are left after filtering. There are three ways to finish, and all three redirect the user to the resume path:

A controller that spreads the authentication over several pages must carry the identifier along, as a hidden field or in the link, so that every request lands on the right authentication. Losing it means the user cannot be brought back.

Where the authentications are kept

The storage has two sides: RedirectFlowRepository, which the protocol flow uses to start an authentication and pick up its outcome, and RedirectAuthenticatorRepository, which is what the controller uses. They work on the same data, so one object, RedirectAuthenticationRepository, implements both.

SessionBasedRedirectAuthenticationRepository keeps them in the user’s session and is installed by default. Replace it to keep them somewhere else, and set the maximum age on it:

final SessionBasedRedirectAuthenticationRepository repository = new SessionBasedRedirectAuthenticationRepository();
repository.setMaxAge(Duration.ofMinutes(10));
provider.setRepository(repository);

Whatever the storage, everything the module puts in it must survive Java serialization, including the token the controller delivers.

Audit events

The server audits each authentication flow, see Auditing. A module that wants to add its own entries to the audit log publishes Spring application events and registers a spring-audit-support transformer for them. Events that the module publishes while the flow is in progress, from the provider or from its controller on the authentication path, are audited under the correlation ID of the flow without the module doing anything. A request to the controller must carry the authnId parameter for this, as it already must for the controller to find the authentication, see The identifier of the authentication.

Post-authentication processing

A PostAuthenticationProcessor runs on the result before it becomes an assertion or a set of tokens, and asserts that the authentication delivered what the request needed. It may also change the result.

The processors of the server configuration run on every result. By default the shared list holds SwedenConnectPostAuthenticationProcessor, which fails a result where a sign message had to be shown but was not. How to add processors for all protocols or for one of them is described in Producers, voters and processors.

A provider may also have processors of its own, which run before those of the server configuration:

provider.getPostAuthenticationProcessors().add(authentication -> {
  if (somethingIsWrong(authentication)) {
    throw new AuthenticationErrorException(AuthenticationError.NOT_AUTHORIZED, "why");
  }
});

Copyright © 2026, Sweden Connect. Licensed under version 2.0 of the Apache License.