spring-authentication-server

Logo

The client registry

License


Every request comes from a requester: a SAML Service Provider known by its entityID, or an OpenID Connect client known by its client_id. Before the server can process a request it needs to know who the requester is. That is what the client registry is for.

The registry is one interface for both protocols. Code that does not care about the protocol, such as the login page or a check that the requester holds a certain mark, works against the protocol-neutral part of what the registry answers. The SAML and OpenID Connect layers reach for the protocol metadata inside it.

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

Looking up a requester

ClientRegistry has one lookup, by protocol and requester identity:

RequesterRecord record = clientRegistry.lookup(AuthenticationProtocol.OIDC, clientId);
if (record == null) {
  // The registry does not know this client.
}

A requester that the registry does not know gives null. That is a normal outcome, and it is not the same thing as a backend that fails. A metadata source that cannot be read, or a resolver service that cannot be reached, throws a ClientRegistryException. A failure is never reported as an unknown requester, so a temporary problem behind the registry never turns into “no such client”.

The record

A RequesterRecord holds a protocol-neutral part and the protocol metadata.

The protocol-neutral part is:

Picking a name or a logotype for the user’s language is done by the record:

String name = record.getDisplayName("sv");
Logo logo = record.getLogo("sv");

The name for the exact language tag is used if there is one, otherwise a name for a language with the same primary subtag, so that sv-SE in the metadata answers a request for sv, and otherwise the name that is not tied to a language.

The protocol metadata is carried as it is and the core never looks at it. Ask for it in its own type:

EntityDescriptor spMetadata = record.getProtocolMetadata(EntityDescriptor.class);
OIDCClientMetadata clientMetadata = record.getProtocolMetadata(OIDCClientMetadata.class);

Marks

Marks are one set of identifiers for both protocols:

Consumers see identifiers and nothing else. That a trust mark is valid is the backend’s business, checked before the type is put in the set, so record.hasMark(mark) means that the requester holds the mark:

if (!record.hasMark("http://id.elegnamnden.se/sprop/1.0/mobile-auth")) {
  ...
}

A mark that the operator has set on a configured client, or on a client held in a repository, is there because the operator vouches for it. Those are not verified.

The organisation number

record.organizationNumber() tells which organisation the requester belongs to, so that code such as login page support, audit and checks for accepting a requester does not have to read SAML or OpenID Connect metadata:

The value is held exactly as the metadata publishes it, and it is not validated. The two protocols use different forms. A SAML value may be 556677-8899, 5566778899 or anything else, since the schema does not restrict it, while an OpenID Connect value is a GLUE URI such as urn:glue:iso6523:0007:5566778899. Code that compares requesters across protocols handles both forms itself.

The organisation number is optional. A requester whose metadata gives none has null in its record.

Backends

The registry itself holds no data. It asks its backends, and each backend answers for one protocol. Several backends may serve the same protocol, in which case they are asked in the order they were given and the first one that knows the requester wins. ClientRegistryBackend is the interface, and DefaultClientRegistry is the registry that asks them.

SAML: metadata sources

Service Provider metadata is read through one or more MetadataSource objects, and MetadataProviderFactory turns them into the OpenSAML provider that SamlMetadataBackend serves. The location of a source decides how it is read:

Location How it is read
An HTTP or HTTPS URL Downloaded as a federation metadata document
An HTTP or HTTPS URL with mdq set Queried entity by entity with the MDQ protocol
A file Read from the file system and reloaded when it changes
Anything else, such as a classpath resource Read once and held in memory
MetadataSource source = MetadataSource.builder(new UrlResource("https://md.swedenconnect.se/role/sp.xml"))
    .validationCertificate(certificate)
    .backupLocation(new File("/var/authn-server/metadata-backup.xml"))
    .build();

SamlMetadataBackend backend =
    new SamlMetadataBackend(MetadataProviderFactory.createMetadataResolver(List.of(source), sslBundles));

Give several sources and they are combined into one, searched in the order they were given. That is how a federation metadata source and a locally kept Service Provider can live side by side.

For a downloading source:

The display names of a record are taken from the mdui:UIInfo extension, then from Organization/OrganizationDisplayName and finally from Organization/OrganizationName. For every language the first name found is the one used. Logotypes come from mdui:UIInfo. The marks are the entity categories that the Service Provider declares. The organisation number comes from the mdorgext:OrganizationNumber extension of md:Organization; a Service Provider without md:Organization, or without the extension, has none.

The SAML configurer creates this backend from the metadata sources it is given, and with Spring Boot the sources are set with the authn-server.saml.metadata-providers properties, see Configuration.

OpenID Connect: three backends

The OpenID Connect side has three backends. They are asked in a configured order, and the default order is configuration, repository, federation.

Every client source has a name, which the Actuator and the audit events use to tell the sources apart. The names must be unique among the client sources of a protocol, and the application does not start if two sources of the same protocol have the same name. The properties source is asked before any OpenID Connect client source added in code.

The first two work from an OidcClientRecord, which is a client_id, the client metadata as Nimbus models it, and the trust mark types that the operator has set:

OIDCClientMetadata metadata = new OIDCClientMetadata();
metadata.setName("Tjänsten", LangTag.parse("sv"));
metadata.setName("The Service", LangTag.parse("en"));
metadata.setLogoURI(URI.create("https://client.example.com/logo.svg"));

ClientRegistryBackend backend = new ConfigurationClientBackend(
    List.of(new OidcClientRecord("https://client.example.com", metadata, Set.of(trustMarkType))));

The display names of a record come from client_name and the logotypes from logo_uri, both in every language that the metadata gives them in. The Sweden Connect profile requires client_name in Swedish and English, so a client that follows the profile gives the login page a name in both languages. The organisation number comes from organization_identifier in the client metadata, for all three backends. A federation client gets it from the resolved metadata, where the Sweden Connect registration intermediate sets it. For a configured client, or a client in a repository, the operator puts it in the client metadata:

metadata.setCustomField("organization_identifier", "urn:glue:iso6523:0007:5566778899");

A configured client, or a client in a repository, that authenticates with a client secret at the token endpoint has the secret in the client_secret field of its metadata, see Client authentication.

The federation backend

The federation backend resolves a client through OpenID Federation, where the client_id of the client is its entity identifier.

With Spring Boot, it is set up from properties: the trust anchor under authn-server.oidc.federation.trust-anchor and the source under authn-server.oidc.federation.clients, with the resolver, the trust mark issuers and the cache, see Clients from OpenID Federation. The source is named federation, and it is on whenever the OpenID Provider is a member of a federation, unless it is turned off. Endpoints that are not configured are found in the entity configurations of the resolver and the issuers, by EntityConfigurationEndpoints.

The OpenID Connect sources are asked in this order: the configured clients of authn-server.oidc.clients (properties), then the sources added in an adapter, and the federation source last. A client known to both the configured clients and the federation is therefore served from the configured clients.

The rest of this section describes the parts, for an application that replaces some of them, for example with a resolver of its own. A FederationClientBackend that an adapter adds replaces the one of the properties, which is then not set up. The backend is put together from a FederationResolver, a TrustMarkRequester, a FederationCache and FederationCacheSettings:

FederationSettings federation = new FederationSettings(
    new FederationSettings.TrustAnchor(trustAnchorEntityId, trustAnchorKeys),
    new FederationSettings.Resolver(resolverEntityId, resolveEndpoint, resolverKeys),
    Map.of(trustMarkType, new FederationSettings.TrustMarkIssuer(issuerEntityId, trustMarkEndpoint, issuerKeys,
        trustMarkStatusEndpoint)));

FederationClientBackend backend = new FederationClientBackend(
    new HttpFederationResolver(federation),
    new HttpTrustMarkRequester(federation),
    new InMemoryFederationCache(),
    FederationCacheSettings.defaults());

HttpFederationResolver calls the resolve endpoint of an external resolver service. The resolve response is a signed JWT, and it is verified with the trust anchor’s keys when the resolver runs at the trust anchor, that is, when it has the same entity identifier, and with the resolver’s own keys otherwise. The keys of a resolver that is not the trust anchor are configured together with it. A resolver given without an endpoint, null, is called at the federation_resolve_endpoint that it publishes in its entity configuration, and the same holds for the trust mark endpoint and the status endpoint of an issuer. The record holds the client metadata of the response, after the metadata policies of the trust chain have been applied by the resolver, and the trust mark types of the response.

Resolving inside the OpenID Provider itself is a later addition. It arrives as another implementation of FederationResolver, and nothing else changes.

The cache

Resolved clients are cached, because reaching the resolver on every request is not an option.

Keeping frequently used clients fresh

FederationCacheRefresher is a background job that resolves a cached client again shortly before its entry expires, so that a request from a client that is used often never waits for the resolver. The job is off by default.

Only clients that are worth it are refreshed. A client qualifies once it has been looked up a configured number of times within a configured period, and one run never refreshes more clients than a configured maximum. Clients that are used rarely are left alone; they are resolved again the next time they are asked for after their entry has expired. The count of lookups is bounded too: at most a configured number of clients are counted, and the client that has not been asked for in the longest time is dropped when a new one arrives.

FederationCacheSettings settings = new FederationCacheSettings(
    Duration.ofHours(12),
    Duration.ofMinutes(1),
    FederationCacheSettings.RefreshSettings.enabled(true));

FederationCacheRefresher refresher = new FederationCacheRefresher(backend);
refresher.start();

Built from the backend, the refresher uses the cache, the resolver and the settings of the backend, and a client that the resolver no longer knows is reported as removed. The constructor that takes the parts one by one is still there, but a refresher built that way reports nothing.

Trust marks on demand

A resolve response carries the trust marks that the resolver knows about. A trust mark that it does not carry can be asked for from its issuer:

RequesterRecord record = clientRegistry.requestMark(requester, trustMarkType);
if (record != null && record.hasMark(trustMarkType)) {
  ...
}

For SAML, asking for a mark never finds anything. It gives the requester’s record unchanged.

Checking the status of trust marks

TrustMarkStatusChecker is a background job that asks the issuer whether the trust marks obtained on demand are still valid, at the issuer’s trust mark status endpoint (OpenID Federation 1.0, Section 8.4). It runs at a configurable interval, one hour by default:

TrustMarkStatusChecker checker = new TrustMarkStatusChecker(cache, federation, httpFederationClient,
    Duration.ofHours(1), Clock.systemUTC());
checker.setEventPublisher(applicationContext);
checker.start();

With Spring Boot, httpFederationClient is the HttpFederationClient bean, so that the status checks count toward the health of the trust mark issuers.

The trust marks of the resolve response are not checked by the job. The resolver has checked them, and they are held no longer than the resolve response is valid.

Several nodes

The cache is also where the background jobs keep their shared state. The backend asks the cache for the object that counts lookups, and the jobs ask it for the lock that decides which node runs a round. With a RedisFederationCache:

With Spring Boot, a FederationCache bean is declared according to authn-server.storage.type, or authn-server.oidc.storage.federation-cache, see Running several nodes. The source set up from the properties uses it, together with the jobs. An application that builds the backend in code builds it and the jobs from the same bean, and from the HttpFederationClient bean that the auto-configuration declares. That client publishes the outcome of every call it makes, which is what the health of the federation services is built from:

@Bean
FederationClientBackend federationBackend(final FederationCache cache, final HttpFederationClient client) {
  return new FederationClientBackend(new HttpFederationResolver(federation, client),
      new HttpTrustMarkRequester(federation, client), cache, settings);
}

The backend is added to the client registry with an AuthnServerConfigurerAdapter, which calls configurer.clientRegistryBackend(backend). The clients that the backend adds and removes are then reported, see Clients added and removed, and the clients it holds can be listed and updated through the clients endpoint.

Deciding which requesters may use the server

Knowing a requester is not the same as accepting it. Once the requester has been found in the registry and its request has been verified, a RequesterAcceptance check decides whether it may use the server. A requester that is not accepted gets the error NOT_AUTHORIZED, answered to the requester as an error response. For SAML that is the status Responder / RequestDenied, and for OpenID Connect the error unauthorized_client.

The check sees the requester’s record: the protocol-neutral part and the protocol metadata. Two implementations are supplied:

Every predicate belongs to one protocol, and only the predicates of the requester’s protocol are evaluated, so a rule for SAML Service Providers never affects an OpenID Connect client. Per protocol, the predicates are combined in one of two modes: ALL, the default, where every predicate must accept the requester, or ANY, where one accepting predicate is enough. A protocol with no predicates accepts every requester.

Two predicates are built in:

With Spring Boot, the configurable check is set up from properties under each protocol’s prefix, for SAML authn-server.saml.requester-acceptance.*, see Configuration, and for OpenID Connect authn-server.oidc.requester-acceptance.*, see Configuration.

A predicate of your own is added with an adapter. A predicate that reads protocol metadata gets it from the record in its own type:

@Bean
AuthnServerConfigurerAdapter onlySwedishOrganisations() {
  return (http, configurer) -> configurer.configurableRequesterAcceptance().addPredicate(new RequesterPredicate() {

    @Override
    public AuthenticationProtocol getProtocol() {
      return AuthenticationProtocol.SAML;
    }

    @Override
    public boolean test(final RequesterRecord record, final ClientRegistry registry) {
      return record.organizationNumber() != null;
    }
  });
}

To replace the check altogether, assign another implementation with configurer.requesterAcceptance(...).


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