OidcAuthService.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.rest.springboot.configuration.OidcCustomConfiguration;
import org.entrystore.rest.springboot.configuration.OidcCustomConfiguration.Provider;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.util.CaseFolding;
import org.springframework.stereotype.Service;
import java.net.URI;
/**
* OIDC counterpart of {@link SamlAuthService}: redirect-URL whitelist validation, email-domain
* based provider routing, and resolution of the per-provider username claim.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OidcAuthService {
private final OidcCustomConfiguration oidcConfiguration;
public boolean isValidRedirectUrl(String url) {
if (StringUtils.isEmpty(url)) {
return false;
}
try {
var uri = URI.create(url);
var scheme = uri.getScheme();
if (scheme != null && !"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
return false;
}
// A hostless URL (relative path, opaque URI) can never match a host whitelist; guard
// explicitly because the whitelist is an immutable List, whose contains(null) throws.
// Hostnames are case-insensitive: normalize the request side; the configured whitelist
// is lowercased at binding time (OidcCustomConfiguration).
var host = uri.getHost();
return host != null && oidcConfiguration.redirectDomainWhitelist().contains(CaseFolding.toLowerCase(host));
} catch (IllegalArgumentException e) {
return false;
}
}
/**
* Where a failed OIDC login goes: {@code requestedUrl} if the redirect whitelist admits it, else the configured
* default failure URL.
*/
public String failureRedirectUrl(String requestedUrl) {
return isValidRedirectUrl(requestedUrl) ? requestedUrl : oidcConfiguration.redirectFailure().url();
}
/**
* The selected registration id together with its provenance, so the caller can classify an
* unknown id correctly: {@code fromRequestParameter} is true only when the id is the caller's
* raw {@code ?provider=} value (a 400), never when it came from configuration — email-domain
* routing or {@code default-provider} — where an unknown id is a server misconfiguration.
*/
public record ProviderSelection(String id, boolean fromRequestParameter) {}
public ProviderSelection findProviderIdForRequest(String username, String provider) {
if (StringUtils.isNotBlank(username)) {
String domain = StringUtils.substringAfter(username, "@");
if (!domain.isEmpty()) {
String providerId = findProviderIdForDomain(domain);
// Unlike the SAML equivalent, an unmatched domain falls through to the explicit
// provider parameter / default provider instead of surfacing a null provider id.
if (providerId != null) {
return new ProviderSelection(providerId, false);
}
}
}
if (StringUtils.isNotBlank(provider)) {
return new ProviderSelection(provider, true);
}
String defaultProvider = oidcConfiguration.defaultProvider();
if (StringUtils.isEmpty(defaultProvider)) {
log.warn("Provider parameter missing and no default OIDC provider configured, unable to select a provider.");
throw new BadRequestException("Unable to initialize OIDC provider configuration. "
+ "Provider parameter missing and no default provider configured.");
}
return new ProviderSelection(defaultProvider, false);
}
private String findProviderIdForDomain(String domain) {
String wildcardProvider = null;
for (var entry : oidcConfiguration.provider().entrySet()) {
var domains = entry.getValue().domains();
if (domains.contains("*")) {
wildcardProvider = entry.getKey();
}
if (domains.contains(CaseFolding.toLowerCase(domain))) {
return entry.getKey();
}
}
// we return the provider matching the wildcard only if we cannot find anything more
// specific for that particular domain, this way we treat wildcards as fallback
return wildcardProvider;
}
public Provider findProviderForCallback(String providerId) {
if (StringUtils.isNotBlank(providerId)) {
return oidcConfiguration.provider().get(providerId);
}
String defaultProvider = oidcConfiguration.defaultProvider();
if (StringUtils.isEmpty(defaultProvider)) {
log.warn("Provider id missing and no default OIDC provider configured, unable to resolve provider configuration. "
+ "Provider from OIDC callback: {}", providerId);
return null;
}
return oidcConfiguration.provider().get(defaultProvider);
}
/**
* The claim mapped to the EntryStore username for the given client registration — resolved
* against the merged ID-token and userinfo claims (see {@code UsernameClaimOidcUserService}).
* A registration without an {@code entrystore.auth.oidc.provider.{id}.*} entry uses the
* default claim — such a provider can still log in existing users, but never auto-provisions
* (see {@code OidcLoginSuccessHandler#isAutoProvisioningEnabled}).
*/
public String usernameClaimFor(String providerId) {
Provider provider = StringUtils.isNotBlank(providerId) ? oidcConfiguration.provider().get(providerId) : null;
return provider != null ? provider.usernameClaim() : Provider.DEFAULT_USERNAME_CLAIM;
}
}