From 2e19f79d00e6484edb253141e822564a7950a961 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Thu, 10 Sep 2026 11:33:33 +0900 Subject: [PATCH] refactor!: move the SSO authenticators to fess-sso-* plugins All four authenticators leave the distribution: saml, spnego, entraid and oic become fess-sso-saml, fess-sso-spnego, fess-sso-entraid and fess-sso-oidc. Together they were 15 jars of third-party SDKs -- java-saml with xmlsec and woodstox, msal4j, the Nimbus OAuth2/OIDC stack, and the SPNEGO library -- on four independent release cadences, in a distribution where most installations configure none of them. What core keeps is the mapping from an sso.type value to the component name Authenticator, so a type core has never heard of reaches a plugin that registers that name. fess_sso.xml keeps ssoManager and the ++ merge point; fess_sso++.xml is deleted, because a component of the same name in the war and in a plugin makes getComponent throw TooManyRegistrationComponentException and runs @PostConstruct twice, and fess_sso+.xml cannot override it either -- RedefinableComponentTagHandler.redefine() refuses a base path containing '+'. A test asserts core ships the base file and no ++ file. SsoAction stays: FessSearchAction references SsoAction.class, and the SAML responses it serves are redirects and a metadata stream rather than JSP. The administration screens stay too, because a plugin jar is mounted at /WEB-INF/classes and cannot supply a JSP -- so admin_general.jsp keeps offering all four types, and the labels and the 47 form fields stay with it. The four credential classes move with their authenticators, out of org.codelibs.fess.app.web.base.login and into org.codelibs.fess.sso.. Nothing resolved them by name: FessLoginAssist handles LocalUserCredential and delegates every other type to auth.resolveCredential(), and they implement only LoginCredential and FessCredential, which stay. Keeping them under org.codelibs.fess.app would have put plugin classes in the smart-deploy scan path for no benefit. getAuthenticator() answered null and said nothing, at any log level. Every caller turns that null into a redirect, so the entire symptom was a GET /sso/ answering 302 to /login/ with a configuration that is complete and correct -- what is missing is a plugin. That became the ordinary upgrade path with this change, so SsoManager now warns once per sso.type, naming the component it looked for and the plugin that provides it. Blank and none say nothing: /sso/ is reachable whether or not SSO is configured, and it is anonymous, so the warning is deduplicated to keep an unauthenticated client from filling the log. Warning, not error: an ERROR line is a notification trigger in Fess, and this is an installation that has not finished rather than a fault. The wording follows the exception StorageClientFactory writes for the same situation. mvn dependency:list -DincludeScope=runtime before and after: 256 runtime artifacts to 241. Exactly 15 removals, no additions, no version changes, totalling 5,827,073 bytes. commons-text stays -- java-saml is one of three requesters, with fess-crawler and handlebars. woodstox-core and stax2-api do leave, reached only through xmlsec 4.0.1, and no artifact left in the war needs a StAX implementation. bcprov-jdk18on is unaffected: fess-crawler-lasta reaches it through bcjmail. Checked across every repository in the workspace: msal4j and azure-json appear in fess-ds-microsoft365, but inside its own shade includes -- it bundles them rather than taking them from the war. No other plugin pom names any of the 15, and no plugin source imports org.codelibs.saml2, org.codelibs.spnego, com.nimbusds, com.microsoft.aad.msal4j, com.ctc.wstx or org.apache.xml.security. deps.xml names none of them, so the child-process class paths under WEB-INF/env are untouched. google-oauth-client is kept, though only the OIDC authenticator used it. Removing the declaration removes no jar -- google-api-client 2.7.2 requests it transitively at 1.36.0 and stays for fess-ds-gsuite -- so the only effect would be to move the choice of version out of this pom. Its types extend google-http-client's, which is pinned here, and a split between them fails at run time rather than at compile time. mvn test: 7770 tests to 7462. The difference reconciles exactly: 312 in the eight test classes that moved to the plugins, one from merging two FessLoginAssistTest cases that used SamlCredential.SamlUser and OpenIdConnectCredential.OpenIdUser as two stand-ins for the same thing -- any FessUser that is not the local document -- and five added to SsoManagerTest for the warning. mvn javadoc:jar passes, which needed AdminSysteminfoAction's {@link} to EntraIdAuthenticator turned into {@code} plus where it went. The ten DI-only tunables fess_sso++.xml carried, on samlAuthenticator and entraidAuthenticator, move to the plugins as commented properties exactly as they were. None is promoted to a configuration key: none appears anywhere in the seven documentation trees, so they are internal defaults rather than settings anyone was told about. --- pom.xml | 60 +- .../systeminfo/AdminSysteminfoAction.java | 4 +- .../app/web/base/login/EntraIdCredential.java | 437 --- .../base/login/OpenIdConnectCredential.java | 185 - .../app/web/base/login/SamlCredential.java | 273 -- .../app/web/base/login/SpnegoCredential.java | 61 - .../codelibs/fess/helper/PluginHelper.java | 8 +- .../org/codelibs/fess/sso/SsoManager.java | 55 + .../sso/entraid/EntraIdAuthenticator.java | 2146 ------------ .../sso/oic/OpenIdConnectAuthenticator.java | 458 --- .../fess/sso/saml/SamlAuthenticator.java | 1605 --------- .../fess/sso/spnego/SpnegoAuthenticator.java | 729 ---- src/main/resources/fess_sso++.xml | 28 - src/main/resources/fess_sso.xml | 16 + .../base/login/EntraIdUserPermissionTest.java | 621 ---- .../web/base/login/FessLoginAssistTest.java | 49 +- .../login/OpenIdConnectCredentialTest.java | 103 - .../transformer/FessXpathTransformerTest.java | 3 +- .../org/codelibs/fess/sso/SsoManagerTest.java | 129 + .../sso/entraid/EntraIdAuthenticatorTest.java | 3022 ----------------- .../oic/OpenIdConnectAuthenticatorTest.java | 537 --- .../fess/sso/saml/SamlAuthenticatorTest.java | 2025 ----------- .../sso/spnego/SpnegoAuthenticatorTest.java | 643 ---- .../SpnegoBasicRealmLibraryParityTest.java | 257 -- .../SpnegoFilterConfigBoundaryTest.java | 228 -- 25 files changed, 260 insertions(+), 13422 deletions(-) delete mode 100644 src/main/java/org/codelibs/fess/app/web/base/login/EntraIdCredential.java delete mode 100644 src/main/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredential.java delete mode 100644 src/main/java/org/codelibs/fess/app/web/base/login/SamlCredential.java delete mode 100644 src/main/java/org/codelibs/fess/app/web/base/login/SpnegoCredential.java delete mode 100644 src/main/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticator.java delete mode 100644 src/main/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticator.java delete mode 100644 src/main/java/org/codelibs/fess/sso/saml/SamlAuthenticator.java delete mode 100644 src/main/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticator.java delete mode 100644 src/main/resources/fess_sso++.xml delete mode 100644 src/test/java/org/codelibs/fess/app/web/base/login/EntraIdUserPermissionTest.java delete mode 100644 src/test/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredentialTest.java delete mode 100644 src/test/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticatorTest.java delete mode 100644 src/test/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticatorTest.java delete mode 100644 src/test/java/org/codelibs/fess/sso/saml/SamlAuthenticatorTest.java delete mode 100644 src/test/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticatorTest.java delete mode 100644 src/test/java/org/codelibs/fess/sso/spnego/SpnegoBasicRealmLibraryParityTest.java delete mode 100644 src/test/java/org/codelibs/fess/sso/spnego/SpnegoFilterConfigBoundaryTest.java diff --git a/pom.xml b/pom.xml index 0c0ca1c393..b2051411c3 100644 --- a/pom.xml +++ b/pom.xml @@ -1140,6 +1140,14 @@ + com.google.oauth-client google-oauth-client @@ -1165,11 +1173,13 @@ google-http-client-jackson2 ${google.http.client.version} - + com.google.http-client google-http-client-gson @@ -1206,11 +1216,6 @@ curl4j ${curl4j.version} - - org.codelibs - spnego - ${spnego.version} - commons-codec commons-codec @@ -1258,26 +1263,6 @@ - - com.microsoft.azure - msal4j - ${msal4j.version} - - - com.github.stephenc.jcip - jcip-annotations - - - com.sun.mail - javax.mail - - - - - com.nimbusds - oauth2-oidc-sdk - ${oauth2.oidc.sdk.version} - org.apache.httpcomponents.client5 httpclient5 @@ -1301,8 +1286,8 @@ shade them. They arrived as transitive dependencies of google-cloud-storage until the GCS client moved to the fess-storage-gcs plugin, so they are declared here to keep that plugin working. The versions are the ones fess-ds-gsuite compiles against. - Fess's own OpenID Connect code uses google-http-client and google-oauth-client - (com.google.api.client.auth/http/json), which are declared separately. --> + google-http-client and google-oauth-client (com.google.api.client.auth/http/json) + are declared separately, for fess-sso-oidc and for this one's own use. --> com.google.api-client google-api-client 2.7.2 @@ -1346,17 +1331,6 @@ ${okhttp.version} test - - org.codelibs - java-saml - ${java.saml.version} - - - jakarta.xml.bind - jakarta.xml.bind-api - - - org.bouncycastle bcprov-jdk18on diff --git a/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java b/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java index 3d4568ae60..bc34bc9be4 100644 --- a/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java +++ b/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java @@ -241,8 +241,8 @@ protected static boolean isPrivateKeyMaterial(final String key) { *

Only OpenID Connect was listed by name before, so the Entra ID client secret was rendered * in cleartext under System Info > Config Info, and was also copied verbatim into the bug * report that users paste into public issues. The legacy {@code aad.*} keys are covered by the - * same shape because {@link org.codelibs.fess.sso.entraid.EntraIdAuthenticator} still reads - * them as a fallback. + * same shape because {@code EntraIdAuthenticator}, now in the fess-sso-entraid plugin, + * still reads them as a fallback. * * @param key the property key to check * @return true if the key matches the SSO client credential shape diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/EntraIdCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/EntraIdCredential.java deleted file mode 100644 index e74cc4496d..0000000000 --- a/src/main/java/org/codelibs/fess/app/web/base/login/EntraIdCredential.java +++ /dev/null @@ -1,437 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.app.web.base.login; - -import static org.codelibs.core.stream.StreamUtil.stream; - -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.fess.entity.FessUser; -import org.codelibs.fess.helper.SystemHelper; -import org.codelibs.fess.sso.entraid.EntraIdAuthenticator; -import org.codelibs.fess.util.ComponentUtil; -import org.lastaflute.web.login.credential.LoginCredential; - -import com.microsoft.aad.msal4j.IAccount; -import com.microsoft.aad.msal4j.IAuthenticationResult; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.JWTParser; - -/** - * Microsoft Entra ID credential implementation for Fess authentication. - * Provides login credential functionality using Entra ID authentication results. - */ -public class EntraIdCredential implements LoginCredential, FessCredential { - - private static final Logger logger = LogManager.getLogger(EntraIdCredential.class); - - private final IAuthenticationResult authResult; - - /** - * Constructs an Entra ID credential with the authentication result. - * @param authResult The authentication result from Entra ID. - */ - public EntraIdCredential(final IAuthenticationResult authResult) { - this.authResult = authResult; - } - - @Override - public String getUserId() { - return authResult.account().username(); - } - - @Override - public String toString() { - return "{" + authResult.account().username() + "}"; - } - - /** - * Gets the Entra ID user associated with this credential. - * @return The Entra ID user instance. - */ - public EntraIdUser getUser() { - return new EntraIdUser(authResult); - } - - /** - * Entra ID user implementation providing user information and permissions. - */ - public static class EntraIdUser implements FessUser { - private static final long serialVersionUID = 1L; - - /** - * How long before the access token expires {@link #refresh()} starts asking MSAL4J for a - * new one. It matches MSAL4J's own expiry buffer, so the token is renewed at the same - * instant it always was; what the guard removes is the silent acquisition -- and the - * Microsoft Graph call behind it -- on every other request. - */ - protected static final long REFRESH_MARGIN = 5 * 60 * 1000L; - - /** - * How long a silent acquisition that failed is left alone before another one is attempted - * for this session. A refresh token that has been revoked, an account that has been - * disabled, and an account a {@code logout()} elsewhere evicted from the shared MSAL4J - * cache all fail for good, and {@link #refresh()} runs on every action request, so - * retrying one unconditionally would put back the per-request round trip - * {@link #REFRESH_MARGIN} was introduced to remove. A minute matches the backoff - * {@code EntraIdAuthenticator} applies to a throttled Microsoft Graph, holds a session - * whose renewal cannot succeed to one acquisition a minute rather than one per request, - * and is short enough that a failure early in {@link #REFRESH_MARGIN} still leaves four - * more attempts before the token actually expires. - */ - protected static final long RENEWAL_THROTTLE_INTERVAL = 60 * 1000L; - - /** User's group memberships. */ - protected volatile String[] groups; - - /** User's role assignments. */ - protected volatile String[] roles; - - /** User's computed permissions. */ - protected volatile String[] permissions; - - /** - * Entra ID authentication result. Volatile because {@link #refresh()} replaces it from - * whichever request thread wins the renewal while the other request threads sharing this - * session-scoped instance keep reading it. - */ - protected volatile IAuthenticationResult authResult; - - /** - * How far this user's group and role permissions have got. Volatile because the resolution - * runs on a TimeoutManager thread while request threads read it. - * - *

Starts PENDING: unlike every other {@code FessUser}, this one is handed out before its - * memberships exist. - */ - protected volatile PermissionState permissionState = PermissionState.PENDING; - - /** - * Whether a membership resolution has ever run to completion for this user -- whether it - * reached Microsoft Graph or fell back to the configured defaults. Volatile for the same - * reason as {@link #permissionState}: written on a TimeoutManager thread, read on request - * threads. - * - *

An explicit flag rather than {@code groups == null}, which is what it used to be - * inferred from: the constructor now seeds the configured defaults, so the memberships are - * never null and every resolution would look like a re-resolution -- keeping the seeded - * defaults forever instead of writing the resolved groups. - */ - protected volatile boolean resolutionCompleted; - - /** - * Set for as long as one thread is inside the silent acquisition in {@link #refresh()}. - * A plain flag rather than a lock: the losing threads must carry on with the token they - * already hold instead of queueing behind an acquisition that can take tens of seconds. - */ - private final AtomicBoolean refreshing = new AtomicBoolean(); - - /** - * Point in time, as epoch milliseconds, until which {@link #refresh()} attempts no further - * silent acquisition. Zero means none has failed yet. Written by whichever request thread - * ran the failing acquisition while the other request threads sharing this session-scoped - * instance keep reading it, hence volatile. - */ - protected volatile long renewalThrottledUntil; - - /** - * Constructs an Entra ID user with the authentication result. - * @param authResult The authentication result from Entra ID. - */ - public EntraIdUser(final IAuthenticationResult authResult) { - this.authResult = authResult; - final EntraIdAuthenticator authenticator = ComponentUtil.getComponent(EntraIdAuthenticator.class); - // The configured defaults are static -- no Graph call stands behind them -- so they - // apply from the first request rather than only once the background resolution lands. - // SsoAction redirects straight to the search page after login, so without this the - // first results a user sees are those of someone holding no groups at all. - authenticator.applyDefaultMemberships(this); - authenticator.scheduleUpdateMemberOf(this); - } - - @Override - public String getName() { - return authResult.account().username(); - } - - @Override - public String[] getRoleNames() { - return roles; - } - - @Override - public String[] getGroupNames() { - return groups; - } - - @Override - public synchronized String[] getPermissions() { - // Synchronized on the same monitor as setGroups/setRoles/resetPermissions. Computing - // the value is a check-then-act -- read `permissions == null`, read `groups`, write - // `permissions` -- and the membership resolution scheduled at login runs on a - // TimeoutManager thread while the user is already searching. Without the lock a reader - // that started before it lands can finish after and overwrite the freshly reset value - // with one computed from stale (or absent) groups; nothing resets it again, so those - // permissions stay wrong for the rest of the session. - if (permissions == null) { - final SystemHelper systemHelper = ComponentUtil.getSystemHelper(); - final Set permissionSet = new HashSet<>(); - final IAccount account = authResult.account(); - final String objectId = getObjectId(); - final String username = account.username(); - if (logger.isDebugEnabled()) { - logger.debug("objectId={}, username={}", objectId, username); - } - if (StringUtil.isNotBlank(objectId)) { - permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(objectId)); - } - permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(username)); - if (ComponentUtil.getFessConfig().isEntraIdUseDomainServices() && username.indexOf('@') >= 0) { - final String[] values = username.split("@"); - if (values.length > 1) { - permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(values[0])); - } - } - stream(groups).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryGroup(s)))); - stream(roles).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryRole(s)))); - permissions = permissionSet.stream().filter(StringUtil::isNotBlank).distinct().toArray(n -> new String[n]); - } - return permissions; - } - - /** - * Reads the {@code oid} claim -- the user's object id in this tenant -- out of the ID - * token. - * - *

Microsoft Graph names a user by that object id, so it is the value a crawler writes - * into the {@code role} field of a document this user owns. {@code IAccount} exposes no - * plain object id of its own: {@code homeAccountId()} is MSAL4J's own account key, and - * {@code getTenantProfiles()} is null on the account an {@code IAuthenticationResult} - * carries. - * - * @return The object id, or null when the ID token carries none. - */ - protected String getObjectId() { - final String idToken = authResult.idToken(); - if (StringUtil.isBlank(idToken)) { - logger.warn("No ID token for {}. The object id permission is not granted.", getName()); - return null; - } - try { - final JWTClaimsSet claimsSet = JWTParser.parse(idToken).getJWTClaimsSet(); - if (claimsSet != null) { - return claimsSet.getStringClaim("oid"); - } - logger.warn("The ID token of {} carries no claims. The object id permission is not granted.", getName()); - } catch (final Exception e) { - logger.warn("Failed to read the oid claim of {}. The object id permission is not granted.", getName(), e); - } - return null; - } - - @Override - public boolean refresh() { - // MSAL4J handles token refresh internally through silent authentication - // Check if token is still valid by comparing absolute timestamps - final long tokenExpiryTime = authResult.expiresOnDate().getTime(); // milliseconds since epoch - final long currentTime = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); // milliseconds since epoch - final boolean expired = tokenExpiryTime < currentTime; - if (!expired && tokenExpiryTime - currentTime > REFRESH_MARGIN) { - // FessBaseAction.godHandPrologue calls this on every action request; a silent - // acquisition is a network call, so it must not happen per request. Until the - // token is close to expiring there is nothing to acquire, and the groups this - // user was given at login are still the ones Entra ID issued them. - if (logger.isDebugEnabled()) { - logger.debug("Token is still valid for {}ms. Skipping silent authentication.", tokenExpiryTime - currentTime); - } - return true; - } - // An expired access token still goes through the acquisition below rather than - // straight out of here. MSAL4J's silent flow spends the cached refresh token, which - // outlives the access token by hours, so a user who was idle across the expiry is - // recoverable -- and giving up instead was permanent, because godHandPrologue - // discards this result: nothing logged the user out, and every later request took the - // same early exit, so the session kept a dead token and stopped re-reading its group - // memberships for as long as it lasted. - // - // Attempting it is not free, though: an acquisition that cannot succeed would be - // repeated on every request of a session that keeps searching, so one failure holds - // the next attempt off for RENEWAL_THROTTLE_INTERVAL. - if (isRenewalThrottled(currentTime)) { - if (logger.isDebugEnabled()) { - logger.debug("A silent authentication has just failed. Not retrying before {}. expired={}", renewalThrottledUntil, - expired); - } - return !expired; - } - // Lastaflute keeps one FessUserBean -- and therefore one EntraIdUser -- as a session - // attribute, and FessBaseAction.godHandPrologue calls refresh() on every action - // request, so all the requests a session has in flight arrive here together once the - // token enters REFRESH_MARGIN. Each of them would see a renewed token and schedule its - // own updateMemberOf task, and the last one to assign would decide which of the - // results authResult ends up holding. One renewal per rollover is enough. - // Not a lock, and deliberately not a synchronized method: the acquisition runs for up - // to the authenticator's acquisition timeout, and getPermissions() takes this object's - // monitor, so waiting here would stall every concurrent search of this user for that - // whole time. - if (!refreshing.compareAndSet(false, true)) { - if (logger.isDebugEnabled()) { - logger.debug("Another request is already renewing the token. Skipping silent authentication."); - } - // A token that has not expired yet lets this request proceed while the winner - // renews. An expired one does not, and whether the winner recovers it is not this - // thread's to report. - return !expired; - } - // Attempt to refresh token using MSAL4J silent authentication - try { - final EntraIdAuthenticator authenticator = ComponentUtil.getComponent(EntraIdAuthenticator.class); - final IAuthenticationResult newResult = authenticator.refreshTokenSilently(this); - if (newResult != null && newResult.expiresOnDate().getTime() >= currentTime) { - // MSAL4J rounds its own buffer down to whole seconds, so for up to a second - // either side of REFRESH_MARGIN it hands back the token it already had. - // Re-reading the directory for a token that did not change would put the - // per-request Graph call straight back. - final boolean renewed = !newResult.accessToken().equals(authResult.accessToken()); - authResult = newResult; - if (renewed) { - // Scheduled, not called: this runs on a request thread, and updateMemberOf - // reaches Microsoft Graph. It resets the permissions itself when it lands. - authenticator.scheduleUpdateMemberOf(this); - } - if (logger.isDebugEnabled()) { - logger.debug("Silent authentication succeeded. renewed={}", renewed); - } - return true; - } - // refreshTokenSilently answers null instead of throwing, so a revoked refresh - // token, a disabled account, and an account evicted from the shared MSAL4J cache - // all arrive here rather than in the catch below. A result that is itself already - // expired is treated the same way: keeping it would leave nothing to renew from - // and no record that the renewal has to be held off. - applyRenewalThrottle(currentTime); - logger.warn("Silent authentication returned no usable access token for {}. expired={}. Next attempt in {} seconds.", - getName(), expired, RENEWAL_THROTTLE_INTERVAL / 1000L); - } catch (final Exception e) { - // At WARN, not DEBUG: this is the same anti-pattern #3218 removed from - // getLoginCredential, where a login that failed was invisible unless debug - // logging happened to be on. The throttle applied first is what keeps a - // persistent failure to one line per interval instead of one per request. - applyRenewalThrottle(currentTime); - logger.warn("Failed to renew the access token of {}. expired={}. Next attempt in {} seconds.", getName(), expired, - RENEWAL_THROTTLE_INTERVAL / 1000L, e); - } finally { - refreshing.set(false); - } - // The silent acquisition produced nothing. A token that has not expired yet still - // authorises this request and MSAL4J is asked again once the throttle lapses, but an - // expired one leaves nothing to carry on with. - return !expired; - } - - /** - * Returns whether a silent acquisition failed recently enough that another one has to wait. - * - * @param currentTime The current time in epoch milliseconds. - * @return True while the silent acquisition has to be skipped. - */ - protected boolean isRenewalThrottled(final long currentTime) { - final long until = renewalThrottledUntil; - return until > 0L && currentTime < until; - } - - /** - * Records that a silent acquisition produced no usable token, so that the requests this - * session makes over the next {@link #RENEWAL_THROTTLE_INTERVAL} do not repeat it. - * - *

Shaped after {@code EntraIdAuthenticator#applyGraphThrottle}, with a fixed interval - * rather than a negotiated one: MSAL4J reports the failure as a null result, so there is - * no {@code Retry-After} to read. - * - * @param currentTime The current time in epoch milliseconds. - */ - protected void applyRenewalThrottle(final long currentTime) { - renewalThrottledUntil = currentTime + RENEWAL_THROTTLE_INTERVAL; - } - - /** - * Gets the Entra ID authentication result. - * @return The authentication result. - */ - public IAuthenticationResult getAuthenticationResult() { - return authResult; - } - - /** - * Sets the user's group memberships. - * @param groups Array of group names. - */ - public synchronized void setGroups(final String[] groups) { - this.groups = groups; - } - - /** - * Sets the user's role assignments. - * @param roles Array of role names. - */ - public synchronized void setRoles(final String[] roles) { - this.roles = roles; - } - - @Override - public PermissionState getPermissionState() { - return permissionState; - } - - /** - * Records how far the group and role resolution has got. - * @param permissionState The state. - */ - public void setPermissionState(final PermissionState permissionState) { - this.permissionState = permissionState; - } - - /** - * Whether a membership resolution has ever run to completion for this user. - * @return True once one has, so that a later one is a re-resolution. - */ - public boolean isResolutionCompleted() { - return resolutionCompleted; - } - - /** - * Records that a membership resolution has run to completion, so that the next one is a - * re-resolution and must not overwrite what this one wrote with the defaults alone. - */ - public void markResolutionCompleted() { - this.resolutionCompleted = true; - } - - /** - * Resets permissions to force recalculation on next getPermissions() call. - * Called from within {@code updateMemberOf}, before the permission state write, once the - * asynchronous membership resolution has the new groups and roles in hand. - */ - public synchronized void resetPermissions() { - this.permissions = null; - } - } -} diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredential.java deleted file mode 100644 index 0e9b8cc3a3..0000000000 --- a/src/main/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredential.java +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.app.web.base.login; - -import static org.codelibs.core.stream.StreamUtil.split; -import static org.codelibs.core.stream.StreamUtil.stream; - -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -import org.codelibs.core.lang.StringUtil; -import org.codelibs.fess.entity.FessUser; -import org.codelibs.fess.helper.SystemHelper; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.fess.util.DocumentUtil; -import org.lastaflute.web.login.credential.LoginCredential; - -/** - * OpenID Connect credential implementation. - */ -public class OpenIdConnectCredential implements LoginCredential, FessCredential { - - private final Map attributes; - - /** - * Creates a new OpenID Connect credential. - * - * @param attributes the attributes from OpenID Connect provider - */ - public OpenIdConnectCredential(final Map attributes) { - this.attributes = attributes; - } - - @Override - public String toString() { - return "{" + getUserId() + "}"; - } - - @Override - public String getUserId() { - return DocumentUtil.getValue(attributes, "email", String.class); - } - - /** - * Gets the user groups. - * - * @return the user groups - */ - public String[] getUserGroups() { - if (attributes.get("groups") instanceof final String singleGroup) { - // A provider that collapses a single-valued claim to a bare JSON string still named a - // group. DocumentUtil answers null when a String is asked for as a String[], which is the - // same answer it gives for a claim that was never sent, so this used to be indistinguishable - // from an absent claim and the user silently got oic.default.groups instead of their own - // group. An empty value is treated like an empty array: the claim was sent, so the default - // does not apply. - return StringUtil.isBlank(singleGroup) ? StringUtil.EMPTY_STRINGS : new String[] { singleGroup.trim() }; - } - String[] userGroups = DocumentUtil.getValue(attributes, "groups", String[].class); - if (userGroups == null) { - userGroups = getDefaultGroupsAsArray(); - } - return userGroups; - } - - /** - * Gets the OpenID Connect user. - * - * @return the OpenID Connect user - */ - public OpenIdUser getUser() { - return new OpenIdUser(getUserId(), getUserGroups(), getDefaultRolesAsArray()); - } - - /** - * Gets the default groups as an array. - * - * @return the default groups - */ - protected static String[] getDefaultGroupsAsArray() { - final String value = ComponentUtil.getFessConfig().getSystemProperty("oic.default.groups"); - if (StringUtil.isBlank(value)) { - return StringUtil.EMPTY_STRINGS; - } - return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n])); - } - - /** - * Gets the default roles as an array. - * - * @return the default roles - */ - protected static String[] getDefaultRolesAsArray() { - final String value = ComponentUtil.getFessConfig().getSystemProperty("oic.default.roles"); - if (StringUtil.isBlank(value)) { - return StringUtil.EMPTY_STRINGS; - } - return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n])); - } - - /** - * OpenID Connect user implementation. - */ - public static class OpenIdUser implements FessUser { - - private static final long serialVersionUID = 1L; - - /** The user name. */ - protected final String name; - - /** The user groups. */ - protected String[] groups; - - /** The user roles. */ - protected String[] roles; - - /** - * The user permissions. - * - *

Lazily computed by {@link #getPermissions()} and never invalidated afterwards. - * {@code volatile} for the same reason as in - * {@link org.codelibs.fess.app.web.base.login.SamlCredential.SamlUser}: the enclosing bean is - * a session attribute shared by concurrent requests, and the array is written after the - * session publication, so an unsynchronized reader could see the reference before the - * elements.

- */ - protected volatile String[] permissions; - - /** - * Creates a new OpenID Connect user. - * - * @param name the user name - * @param groups the user groups - * @param roles the user roles - */ - protected OpenIdUser(final String name, final String[] groups, final String[] roles) { - this.name = name; - this.groups = groups; - this.roles = roles; - } - - @Override - public String getName() { - return name; - } - - @Override - public String[] getRoleNames() { - return roles; - } - - @Override - public String[] getGroupNames() { - return groups; - } - - @Override - public String[] getPermissions() { - if (permissions == null) { - final SystemHelper systemHelper = ComponentUtil.getSystemHelper(); - final Set permissionSet = new HashSet<>(); - permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(name)); - stream(groups).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryGroup(s)))); - stream(roles).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryRole(s)))); - permissions = permissionSet.toArray(new String[permissionSet.size()]); - } - return permissions; - } - - } -} diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/SamlCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/SamlCredential.java deleted file mode 100644 index 668bfaf8a7..0000000000 --- a/src/main/java/org/codelibs/fess/app/web/base/login/SamlCredential.java +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.app.web.base.login; - -import static org.codelibs.core.stream.StreamUtil.split; -import static org.codelibs.core.stream.StreamUtil.stream; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import org.codelibs.core.lang.StringUtil; -import org.codelibs.fess.entity.FessUser; -import org.codelibs.fess.helper.SystemHelper; -import org.codelibs.fess.mylasta.direction.FessConfig; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.saml2.Auth; -import org.lastaflute.web.login.credential.LoginCredential; - -/** - * Credential for SAML authentication. - */ -public class SamlCredential implements LoginCredential, FessCredential { - - private final Map> attributes; - - private final String nameId; - - private final String nameIdFormat; - - private final String sessionIndex; - - private final String nameidNameQualifier; - - private final String nameidSPNameQualifier; - - /** - * Constructor. - * @param auth The SAML authentication. - */ - public SamlCredential(final Auth auth) { - attributes = auth.getAttributes(); - nameId = auth.getNameId(); - nameIdFormat = auth.getNameIdFormat(); - sessionIndex = auth.getSessionIndex(); - nameidNameQualifier = auth.getNameIdNameQualifier(); - nameidSPNameQualifier = auth.getNameIdSPNameQualifier(); - } - - @Override - public String toString() { - return "{" + getUserId() + "}"; - } - - @Override - public String getUserId() { - return nameId; - } - - /** - * Gets the SAML user. - * @return The SAML user. - */ - public SamlUser getUser() { - return new SamlUser(nameId, sessionIndex, nameIdFormat, nameidNameQualifier, nameidSPNameQualifier, getDefaultGroupsAsArray(), - getDefaultRolesAsArray()); - } - - /** - * Gets the default groups as an array. - * @return The default groups as an array. - */ - protected String[] getDefaultGroupsAsArray() { - final List list = new ArrayList<>(); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String key = fessConfig.getSystemProperty("saml.attribute.group.name", "memberOf"); - if (StringUtil.isNotBlank(key)) { - final List nameList = attributes.get(key); - if (nameList != null) { - list.addAll(nameList); - } - } - final String value = fessConfig.getSystemProperty("saml.default.groups"); - if (StringUtil.isNotBlank(value)) { - split(value, ",").of(stream -> stream.forEach(list::add)); - } - return list.stream().filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n]); - } - - /** - * Gets the default roles as an array. - * @return The default roles as an array. - */ - protected String[] getDefaultRolesAsArray() { - final List list = new ArrayList<>(); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String key = fessConfig.getSystemProperty("saml.attribute.role.name"); - if (StringUtil.isNotBlank(key)) { - final List nameList = attributes.get(key); - if (nameList != null) { - list.addAll(nameList); - } - } - final String value = fessConfig.getSystemProperty("saml.default.roles"); - if (StringUtil.isNotBlank(value)) { - split(value, ",").of(stream -> stream.forEach(list::add)); - } - return list.stream().filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n]); - } - - /** - * Represents a SAML user. - */ - public static class SamlUser implements FessUser { - - private static final long serialVersionUID = 1L; - - /** - * The groups of the user. - */ - protected String[] groups; - - /** - * The roles of the user. - */ - protected String[] roles; - - /** - * The permissions of the user. - * - *

Lazily computed by {@link #getPermissions()} and never invalidated afterwards. - * {@code volatile} because the enclosing {@link org.codelibs.fess.mylasta.action.FessUserBean} - * is a session attribute read by every concurrent request of that session, while the array - * is written on whichever thread calls {@code getPermissions()} first. That write is not - * ordered by the session publication -- LastaFlute stores the bean before the login success - * callback that warms this cache -- so without {@code volatile} a request already in flight - * could observe the reference while the elements are still unwritten and see an incomplete - * permission set. Two threads racing to compute it is harmless; they produce equal arrays.

- */ - protected volatile String[] permissions; - - /** - * The name ID of the user. - */ - protected String nameId; - - /** - * The session index of the user. - */ - protected String sessionIndex; - - /** - * The name ID format of the user. - */ - protected String nameIdFormat; - - /** - * The name ID name qualifier of the user. - */ - protected String nameidNameQualifier; - - /** - * The name ID SP name qualifier of the user. - */ - protected String nameidSPNameQualifier; - - /** - * Constructor. - * @param nameId The name ID. - * @param sessionIndex The session index. - * @param nameIdFormat The name ID format. - * @param nameidNameQualifier The name ID name qualifier. - * @param nameidSPNameQualifier The name ID SP name qualifier. - * @param groups The groups. - * @param roles The roles. - */ - public SamlUser(final String nameId, final String sessionIndex, final String nameIdFormat, final String nameidNameQualifier, - final String nameidSPNameQualifier, final String[] groups, final String[] roles) { - this.nameId = nameId; - this.sessionIndex = sessionIndex; - this.nameIdFormat = nameIdFormat; - this.nameidNameQualifier = nameidNameQualifier; - this.nameidSPNameQualifier = nameidSPNameQualifier; - this.groups = groups; - this.roles = roles; - } - - @Override - public String getName() { - return nameId; - } - - @Override - public String[] getRoleNames() { - return roles; - } - - @Override - public String[] getGroupNames() { - return groups; - } - - @Override - public String[] getPermissions() { - if (permissions == null) { - final SystemHelper systemHelper = ComponentUtil.getSystemHelper(); - final Set permissionSet = new HashSet<>(); - permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(nameId)); - stream(groups).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryGroup(s)))); - stream(roles).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryRole(s)))); - permissions = permissionSet.toArray(new String[permissionSet.size()]); - } - return permissions; - } - - /** - * Gets the session index. - * @return The session index. - */ - public String getSessionIndex() { - return sessionIndex; - } - - /** - * Gets the name ID format. - * @return The name ID format. - */ - public String getNameIdFormat() { - return nameIdFormat; - } - - /** - * Gets the name ID name qualifier. - * @return The name ID name qualifier. - */ - public String getNameidNameQualifier() { - return nameidNameQualifier; - } - - /** - * Gets the name ID SP name qualifier. - * @return The name ID SP name qualifier. - */ - public String getNameidSPNameQualifier() { - return nameidSPNameQualifier; - } - - @Override - public String toString() { - return "SamlUser [groups=" + Arrays.toString(groups) + ", roles=" + Arrays.toString(roles) + ", permissions=" - + Arrays.toString(permissions) + ", nameId=" + nameId + ", sessionIndex=" + sessionIndex + ", nameIdFormat=" - + nameIdFormat + ", nameidNameQualifier=" + nameidNameQualifier + ", nameidSPNameQualifier=" + nameidSPNameQualifier - + "]"; - } - - } -} diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/SpnegoCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/SpnegoCredential.java deleted file mode 100644 index 107c13f923..0000000000 --- a/src/main/java/org/codelibs/fess/app/web/base/login/SpnegoCredential.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.app.web.base.login; - -import org.lastaflute.web.login.credential.LoginCredential; - -/** - * SPNEGO authentication credential implementation. - * - * This class represents login credentials obtained through SPNEGO (Security Provider - * Negotiation Protocol) authentication. It contains the username extracted from the - * SPNEGO authentication process, typically from a Kerberos ticket. - */ -public class SpnegoCredential implements LoginCredential, FessCredential { - - /** The username extracted from SPNEGO authentication. */ - private final String username; - - /** - * Constructs a new SpnegoCredential with the specified username. - * - * @param username The username obtained from SPNEGO authentication - */ - public SpnegoCredential(final String username) { - this.username = username; - } - - /** - * Gets the user identifier from this credential. - * - * @return The username from SPNEGO authentication - */ - @Override - public String getUserId() { - return username; - } - - /** - * Returns a string representation of this credential. - * - * @return A string representation containing the username in braces - */ - @Override - public String toString() { - return "{" + username + "}"; - } - -} \ No newline at end of file diff --git a/src/main/java/org/codelibs/fess/helper/PluginHelper.java b/src/main/java/org/codelibs/fess/helper/PluginHelper.java index a37a7bb58e..daa2a33801 100644 --- a/src/main/java/org/codelibs/fess/helper/PluginHelper.java +++ b/src/main/java/org/codelibs/fess/helper/PluginHelper.java @@ -610,9 +610,11 @@ public enum ArtifactType { */ STORAGE("fess-storage"), // /** - * Single sign-on plugins, contributing the {@code SsoAuthenticator} that {@code sso.type} - * selects. No such plugin exists as of 15.9 - the authenticators still ship in core - so - * this reserves the prefix for when they are split out. + * Single sign-on plugins, each contributing the {@code Authenticator} that + * {@code sso.type} selects, plus the identity library it needs, so the distribution does + * not carry one for every installation. There are four: fess-sso-saml, fess-sso-spnego, + * fess-sso-entraid and fess-sso-oidc. Core keeps SsoManager and the /sso/ endpoints, and + * serves no type on its own. */ SSO("fess-sso"), // /** Unknown/generic JAR files */ diff --git a/src/main/java/org/codelibs/fess/sso/SsoManager.java b/src/main/java/org/codelibs/fess/sso/SsoManager.java index 42311a4612..2d47374f10 100644 --- a/src/main/java/org/codelibs/fess/sso/SsoManager.java +++ b/src/main/java/org/codelibs/fess/sso/SsoManager.java @@ -17,10 +17,13 @@ import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.codelibs.core.lang.StringUtil; import org.codelibs.fess.Constants; import org.codelibs.fess.mylasta.action.FessUserBean; import org.codelibs.fess.util.ComponentUtil; @@ -34,6 +37,11 @@ * It manages registered SSO authenticators, determines when SSO is available, * and delegates authentication operations to the appropriate SSO provider based * on the current configuration. + * + *

No authenticator ships in the distribution: each one is a fess-sso-* plugin that contributes + * its {@code Authenticator} component through {@code fess_sso++.xml}. What this class + * owns is the mapping from an {@code sso.type} value to that component name, so a plugin needs no + * change here to be reachable.

*/ public class SsoManager { /** Logger for this class. */ @@ -42,6 +50,16 @@ public class SsoManager { /** List of registered SSO authenticators. */ protected final List authenticatorList = new ArrayList<>(); + /** + * The {@code sso.type} values already reported as unserved, so that each one is logged once + * rather than once per request. + * + *

{@code /sso/} is anonymous, and the same miss is hit on every visit to it, so a warning + * per attempt is a log an unauthenticated client can fill. The set is bounded by the number of + * distinct values the setting has held for the life of the JVM, which is one.

+ */ + protected final Set unservedSsoTypes = ConcurrentHashMap.newKeySet(); + /** * Default constructor for creating a new SsoManager instance. */ @@ -111,6 +129,12 @@ protected T withAuthenticator(final Function operation) /** * Gets the SSO authenticator instance for the configured SSO type. * + *

The type is resolved to the component name {@code Authenticator} rather than to + * a class this package knows about, which is what lets the authenticators ship as plugins: a + * type core has never heard of reaches a plugin that registers that name. A type whose plugin + * is not installed is therefore a configuration error rather than a missing class, and is + * reported by {@link #reportUnservedSsoType}.

+ * * @return The SSO authenticator instance, or null if not found */ protected SsoAuthenticator getAuthenticator() { @@ -123,9 +147,40 @@ protected SsoAuthenticator getAuthenticator() { if (ComponentUtil.hasComponent(name)) { return ComponentUtil.getComponent(name); } + reportUnservedSsoType(ssoType, name); return null; } + /** + * Reports that no component serves the configured {@code sso.type}. + * + *

Every caller of {@link #getAuthenticator()} answers null when this happens, and + * {@code SsoAction.index()} turns that null into {@code errors.sso_login_error} and a redirect + * to the login page. Without this the whole symptom is a {@code GET /sso/} that answers 302 to + * {@code /login/}, with nothing in the log at any level to say why -- the configuration is + * complete and correct, and the piece that is missing is a plugin. That became the ordinary + * upgrade path when the authenticators were split out of core, so it is said out loud.

+ * + *

WARN rather than ERROR: in Fess an ERROR line is a notification trigger, and this is a + * deployment that has not finished installing rather than a fault at run time. Blank and + * {@code none} are the states of a deployment that does not use SSO at all and say nothing -- + * {@code /sso/} is reachable whether or not it is configured.

+ * + * @param ssoType the configured type, after the legacy {@code aad} mapping + * @param componentName the component name it resolved to + */ + protected void reportUnservedSsoType(final String ssoType, final String componentName) { + if (StringUtil.isBlank(ssoType) || Constants.NONE.equals(ssoType)) { + return; + } + if (logger.isWarnEnabled() && unservedSsoTypes.add(ssoType)) { + logger.warn("No SSO authenticator is registered as {} for sso.type={}. Every authenticator ships as a fess-sso-* plugin: " + + "install fess-sso-saml for saml, fess-sso-spnego for spnego, fess-sso-entraid for entraid " + + "(or the legacy aad), or fess-sso-oidc for oic. Until then every request to /sso/ is redirected " + + "back to the login page.", componentName, ssoType); + } + } + /** * Gets the configured SSO type from the system configuration. * diff --git a/src/main/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticator.java b/src/main/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticator.java deleted file mode 100644 index 1bff19dfd8..0000000000 --- a/src/main/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticator.java +++ /dev/null @@ -1,2146 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.entraid; - -import static org.codelibs.core.stream.StreamUtil.split; - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.core.misc.Pair; -import org.codelibs.core.timer.TimeoutManager; -import org.codelibs.curl.Curl; -import org.codelibs.curl.CurlException; -import org.codelibs.curl.CurlRequest; -import org.codelibs.curl.CurlResponse; -import org.codelibs.fess.app.web.base.login.ActionResponseCredential; -import org.codelibs.fess.app.web.base.login.EntraIdCredential; -import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser; -import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver; -import org.codelibs.fess.crawler.Constants; -import org.codelibs.fess.entity.FessUser.PermissionState; -import org.codelibs.fess.exception.SsoLoginException; -import org.codelibs.fess.exception.SsoStateException; -import org.codelibs.fess.mylasta.action.FessUserBean; -import org.codelibs.fess.mylasta.direction.FessConfig; -import org.codelibs.fess.sso.SsoAuthenticator; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.fess.util.DocumentUtil; -import org.codelibs.fess.util.SearchEngineCurl; -import org.dbflute.optional.OptionalEntity; -import org.dbflute.optional.OptionalThing; -import org.lastaflute.web.login.credential.LoginCredential; -import org.lastaflute.web.response.HtmlResponse; -import org.lastaflute.web.util.LaRequestUtil; - -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import com.google.common.util.concurrent.UncheckedExecutionException; -import com.microsoft.aad.msal4j.AuthorizationCodeParameters; -import com.microsoft.aad.msal4j.ConfidentialClientApplication; -import com.microsoft.aad.msal4j.IAccount; -import com.microsoft.aad.msal4j.IAuthenticationResult; -import com.microsoft.aad.msal4j.RefreshTokenParameters; -import com.microsoft.aad.msal4j.SilentParameters; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.JWTParser; -import com.nimbusds.oauth2.sdk.AuthorizationCode; -import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse; -import com.nimbusds.openid.connect.sdk.AuthenticationResponse; -import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser; -import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse; - -import jakarta.annotation.PostConstruct; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - -/** - * Microsoft Entra ID SSO authenticator implementation. - * Handles OAuth2/OpenID Connect authentication flow with Entra ID. - */ -public class EntraIdAuthenticator implements SsoAuthenticator { - - private static final Logger logger = LogManager.getLogger(EntraIdAuthenticator.class); - - /** - * Default constructor for EntraIdAuthenticator. - */ - public EntraIdAuthenticator() { - // Default constructor - } - - // New configuration keys for Entra ID - /** Configuration key for Entra ID state time-to-live. */ - protected static final String ENTRAID_STATE_TTL = "entraid.state.ttl"; - - /** Default state time-to-live in seconds. */ - protected static final String DEFAULT_STATE_TTL = "3600"; - - /** - * Authority used when none is configured. Also used when the configured value is present but - * blank: an empty authority would make {@link #getAuthUrl} build a scheme-less, and therefore - * relative, redirect that sends the browser back into Fess instead of to Microsoft. - */ - protected static final String DEFAULT_AUTHORITY = "https://login.microsoftonline.com/"; - - /** Configuration key for Entra ID authority URL. */ - protected static final String ENTRAID_AUTHORITY = "entraid.authority"; - - /** Configuration key for Entra ID tenant ID. */ - protected static final String ENTRAID_TENANT = "entraid.tenant"; - - /** Configuration key for Entra ID client secret. */ - protected static final String ENTRAID_CLIENT_SECRET = "entraid.client.secret"; - - /** Configuration key for Entra ID client ID. */ - protected static final String ENTRAID_CLIENT_ID = "entraid.client.id"; - - /** Configuration key for Entra ID reply URL. */ - protected static final String ENTRAID_REPLY_URL = "entraid.reply.url"; - - /** Configuration key for the OAuth2 response mode of the authorization request. */ - protected static final String ENTRAID_RESPONSE_MODE = "entraid.response.mode"; - - /** Configuration key for Entra ID default groups. */ - protected static final String ENTRAID_DEFAULT_GROUPS = "entraid.default.groups"; - - /** Configuration key for Entra ID default roles. */ - protected static final String ENTRAID_DEFAULT_ROLES = "entraid.default.roles"; - - // Legacy configuration keys for backward compatibility (Azure AD) - /** Legacy configuration key for Azure AD state time-to-live. */ - protected static final String AAD_STATE_TTL = "aad.state.ttl"; - - /** Legacy configuration key for Azure AD authority URL. */ - protected static final String AAD_AUTHORITY = "aad.authority"; - - /** Legacy configuration key for Azure AD tenant ID. */ - protected static final String AAD_TENANT = "aad.tenant"; - - /** Legacy configuration key for Azure AD client secret. */ - protected static final String AAD_CLIENT_SECRET = "aad.client.secret"; - - /** Legacy configuration key for Azure AD client ID. */ - protected static final String AAD_CLIENT_ID = "aad.client.id"; - - /** Legacy configuration key for Azure AD reply URL. */ - protected static final String AAD_REPLY_URL = "aad.reply.url"; - - /** Legacy configuration key for the OAuth2 response mode. */ - protected static final String AAD_RESPONSE_MODE = "aad.response.mode"; - - /** Response mode that returns the authorization code in the callback query string. */ - protected static final String RESPONSE_MODE_QUERY = "query"; - - /** Response mode that returns the authorization code in a form POST to the callback. */ - protected static final String RESPONSE_MODE_FORM_POST = "form_post"; - - /** Legacy configuration key for Azure AD default groups. */ - protected static final String AAD_DEFAULT_GROUPS = "aad.default.groups"; - - /** Legacy configuration key for Azure AD default roles. */ - protected static final String AAD_DEFAULT_ROLES = "aad.default.roles"; - - /** Session attribute key for storing Entra ID states. */ - protected static final String STATES = "entraidStates"; - - /** OAuth2 state parameter name. */ - protected static final String STATE = "state"; - - /** OAuth2 error parameter name. */ - protected static final String ERROR = "error"; - - /** OAuth2 error description parameter name. */ - protected static final String ERROR_DESCRIPTION = "error_description"; - - /** OAuth2 error URI parameter name. */ - protected static final String ERROR_URI = "error_uri"; - - /** OpenID Connect ID token parameter name. */ - protected static final String ID_TOKEN = "id_token"; - - /** OAuth2 authorization code parameter name. */ - protected static final String CODE = "code"; - - /** Microsoft Graph error code returned when the application lacks the required permission. */ - protected static final String PERMISSION_DENIED_ERROR_CODE = "Authorization_RequestDenied"; - - /** HTTP status Microsoft Graph answers with while it is throttling the caller. */ - protected static final int HTTP_TOO_MANY_REQUESTS = 429; - - /** HTTP status Microsoft Graph answers with while it is temporarily unavailable. */ - protected static final int HTTP_SERVICE_UNAVAILABLE = 503; - - /** Backoff applied when a throttled response carries no usable {@code Retry-After} header. */ - protected static final long DEFAULT_GRAPH_THROTTLE_SECONDS = 60L; - - /** - * Upper bound on the backoff. {@code Retry-After} is whatever the service says it is, and an - * unreasonably large value would leave nested groups unresolved for the rest of the day. - */ - protected static final long MAX_GRAPH_THROTTLE_SECONDS = 60L * 60L; - - /** The Microsoft Graph scope that asks for the permissions granted to the app registration. */ - protected static final String GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"; - - /** Base URL of the Microsoft Graph v1.0 endpoint. */ - protected static final String GRAPH_V1_URL = "https://graph.microsoft.com/v1.0"; - - /** - * Scopes requested at the v2.0 authorization endpoint. msal4j already prepends - * {@code openid profile offline_access} to the token request (its - * {@code OAuthAuthorizationGrant.COMMON_SCOPES}), so naming them here as well means consent is - * asked for the same set the token exchange goes on to request, rather than relying on the - * app registration's static permissions happening to include them. - */ - protected static final String V2_SCOPES = "openid profile offline_access " + GRAPH_DEFAULT_SCOPE; - - /** {@link #V2_SCOPES} percent-encoded once, since it never varies. */ - protected static final String V2_SCOPES_ENCODED = URLEncoder.encode(V2_SCOPES, Constants.UTF_8_CHARSET); - - /** - * Response parameters whose values are credentials. Their values are truncated before being - * written to a debug log; every other parameter is logged verbatim so that a failed login can - * still be diagnosed from {@code state}, {@code error} and {@code error_description}. - */ - protected static final Set SENSITIVE_PARAMS = Set.of(CODE, ID_TOKEN, "access_token", "refresh_token", "client_secret"); - - /** Number of leading characters kept when a secret is written to a debug log. */ - protected static final int MASK_PREFIX_LENGTH = 8; - - /** - * Truncates a secret so it can be correlated across log lines without being usable. - * Null and empty values are passed through, because several call sites log a field that - * the identity provider may not have sent at all. - * - * @param value The value to mask. - * @return The masked value. - */ - protected static String maskSecret(final String value) { - if (StringUtil.isEmpty(value)) { - return value; - } - return value.substring(0, Math.min(MASK_PREFIX_LENGTH, value.length())) + "***"; - } - - /** - * Drops the query string from a URL before it is written to a debug log. The query string can - * carry the authorization code, and every parameter it holds is already logged separately via - * {@link #maskParams(Map)}. - * - * @param url The URL to strip. - * @return The URL without its query string. - */ - protected static String maskQueryString(final String url) { - if (url == null) { - return null; - } - final int index = url.indexOf('?'); - return index < 0 ? url : url.substring(0, index); - } - - /** - * Returns a copy of the response parameters with credential values masked, for logging. - * The key set is preserved so the log still shows which artifacts the identity provider sent. - * - * @param params The response parameters. - * @return A new map safe to write to a log. - */ - protected static Map> maskParams(final Map> params) { - final Map> maskedParams = new LinkedHashMap<>(); - params.forEach((key, values) -> { - if (key != null && SENSITIVE_PARAMS.contains(key.toLowerCase(Locale.ENGLISH))) { - maskedParams.put(key, values.stream().map(EntraIdAuthenticator::maskSecret).collect(Collectors.toList())); - } else { - maskedParams.put(key, values); - } - }); - return maskedParams; - } - - /** Timeout for token acquisition in milliseconds. */ - protected long acquisitionTimeout = 30 * 1000L; - - /** Cache for storing group information to reduce API calls. */ - protected Cache> groupCache; - - /** Group cache expiry time in seconds. */ - protected long groupCacheExpiry = 10 * 60L; - - /** - * Maximum number of groups kept in {@link #groupCache}. The cache is keyed by group id and the - * configured permission fields, so a tenant with many groups would otherwise grow it without - * bound until every entry expired. - */ - protected int maxGroupCacheSize = 10000; - - /** Maximum depth for processing nested groups to prevent infinite loops. */ - protected int maxGroupDepth = 10; - - /** - * How many consecutive parent group lookups Microsoft Graph may fail to answer before - * {@link #updateMemberOf} stops walking the rest of the user's direct groups. - * - *

The walk costs one {@code POST /groups/{id}/getMemberGroups} per direct group. A 429 or a - * 503 records a tenant-wide backoff, so the rest of that walk is skipped without reaching - * Graph at all; a 500/502/504 or a transport failure -- DNS, connection refused, or the - * {@link #graphConnectTimeout} / {@link #graphReadTimeout} expiring -- records nothing, so - * without this bound every direct group costs a full request and a stack trace, on every - * login, each waiting out the timeouts. That runs on corelib's shared {@code TimeoutManager} - * pool, whose {@code CallerRunsPolicy} pushes the overflow onto the timer thread itself. - * - *

Consecutive rather than total is deliberate: one permanently broken group id must not - * stop the rest of the walk, while a Graph that has stopped answering trips the bound - * immediately. - */ - protected int maxConsecutiveGroupLookupFailures = 3; - - /** - * Connection timeout for Microsoft Graph requests in milliseconds. curl4j leaves this unset, - * which means an unbounded wait, and the direct-membership lookup runs on the login thread. - */ - protected int graphConnectTimeout = 10 * 1000; - - /** Read timeout for Microsoft Graph requests in milliseconds. See {@link #graphConnectTimeout}. */ - protected int graphReadTimeout = 30 * 1000; - - /** - * Maximum number of unfinished authorization attempts kept per session. Each redirect to the - * authorization endpoint stores one; without a cap, a client that keeps starting logins - * without finishing one grows the session attribute without bound. - */ - protected int maxStates = 10; - - /** - * Point in time, as epoch milliseconds, until which Microsoft Graph asked us to stop calling - * it. Zero means it never did. Read on the login path, written from whichever thread was - * throttled, hence volatile. - */ - protected volatile long graphThrottledUntil; - - /** - * Shared MSAL4J client application together with the configuration it was built from. - * - *

A single reference is what makes the pair consistent: with the application and its key in - * two separate fields, a reader interleaved between the two writes pairs the old application - * with the new key and hands back the stale one indefinitely. - */ - protected volatile ClientApplicationHolder clientApplicationHolder; - - /** - * The accounts whose tokens are in the shared application's cache, in least-recently-acquired - * order. See {@link #maxCachedAccounts} for why this exists; access order rather than - * insertion order because the entry worth evicting is the one whose tokens have gone longest - * without being used, not the one that logged in first -- a session that has been alive for - * days is the last one to throw away. - * - *

Guarded by its own monitor. It is only ever touched after an acquisition or a logout, - * so contention is a fraction of what the login path already serialises on. - */ - protected final Map cachedAccounts = new LinkedHashMap<>(16, 0.75f, true); - - /** - * Maximum number of accounts kept in the shared application's token cache. - * - *

MSAL4J's {@code TokenCache} is five plain maps with no size bound and no expiry, and - * {@code removeAccount} is the only thing that takes anything out of them. While the - * application was rebuilt per call there was nothing to accumulate; now that one instance is - * shared so that silent refresh can work at all, every account that logged in and never - * pressed Logout keeps an access token, a refresh token and an ID token resident until the - * JVM restarts. The keys are account-scoped, so a user who logs in repeatedly overwrites - * their own entry -- the bound is distinct accounts, not logins -- but a large tenant still - * reaches a size worth capping, and a refresh token is good for up to 90 days. - * - *

The default is high enough that an ordinary deployment never reaches it. When it is - * reached, the cost of evicting a live session is one silent re-authentication: a failed - * silent acquisition leaves {@code refresh()} returning true until the access token actually - * expires, and the re-login that follows goes through an unexpired Entra ID session. - */ - protected int maxCachedAccounts = 10000; - - /** - * Initializes the Entra ID authenticator. - * Registers this authenticator with the SSO manager and sets up group cache. - */ - @PostConstruct - public void init() { - if (logger.isDebugEnabled()) { - logger.debug("Initializing {}", this.getClass().getSimpleName()); - } - ComponentUtil.getSsoManager().register(this); - groupCache = createGroupCache(); - } - - /** - * Builds the parent group cache. Both bounds matter: the expiry keeps a group whose - * membership changed from being served forever, and the size keeps a large tenant from - * holding every group it ever resolved until the expiry comes round. - * - * @return The cache. - */ - protected Cache> createGroupCache() { - return CacheBuilder.newBuilder().maximumSize(maxGroupCacheSize).expireAfterWrite(groupCacheExpiry, TimeUnit.SECONDS).build(); - } - - @Override - public LoginCredential getLoginCredential() { - return LaRequestUtil.getOptionalRequest().map(request -> { - if (logger.isDebugEnabled()) { - logger.debug("Logging in with Entra ID Authenticator"); - } - final HttpSession session = request.getSession(false); - if (containsAuthenticationData(request)) { - if (session != null) { - try { - return processAuthenticationData(request); - } catch (final SsoLoginException e) { - throw e; - } catch (final Exception e) { - // Wrapped rather than returned as null so SsoAction logs it at WARN and - // shows the SSO error message, the same as it already does for the other - // authenticators. Swallowing it here left a failed login invisible unless - // DEBUG logging happened to be on. - throw new SsoLoginException("Failed to process a login request on Entra ID.", e); - } - } - if (!hasExpiredSession(request)) { - // No session, and no session id came back either: the browser is not returning - // the cookie at all (form_post with SameSite=Lax or Strict, or cookies off). - // Redirecting would send the user straight back here in the same state, so - // this is where the loop has to stop. Returning null makes SsoAction show the - // SSO error message and fall back to the local login form. - logger.warn(""" - Received an Entra ID authentication response without a session.\ - The session cookie was not sent back with the callback request.\ - See tomcat.sameSiteCookies in tomcat_config.properties."""); - return null; - } - // The browser did return a session id and the container rejected it, so cookies - // demonstrably work and the session merely expired while the user was at - // Microsoft. Start the login again rather than dropping them on a login form that - // has no SSO link. This cannot loop: getAuthUrl creates a fresh session, so the - // next callback either finds it or arrives with no session id at all, which is - // the branch above. - if (logger.isDebugEnabled()) { - logger.debug("The session of an Entra ID callback had expired. Restarting the login."); - } - } - - validateConfiguration(); - return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(getAuthUrl(request))); - }).orElse(null); - } - - /** - * Returns whether the request carries a session id that the container no longer recognises. - * - *

This is what tells an expired session apart from a browser that is not sending the - * cookie: a request with no session id at all cannot have lost one. - * - * @param request The HTTP servlet request. - * @return True if a session id was sent and it is no longer valid. - */ - protected boolean hasExpiredSession(final HttpServletRequest request) { - return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid(); - } - - /** - * Fails the login when Entra ID cannot possibly answer for want of configuration. - * - *

Without this an unconfigured server redirects to - * {@code https://login.microsoftonline.com//oauth2/v2.0/authorize?...&client_id=} and logs - * nothing, so the administrator sees only a Microsoft error page. It is thrown from here - * rather than from the {@link ActionResponseCredential} supplier because {@code SsoAction} - * executes that supplier outside the block that catches {@link SsoLoginException}, and it is - * not checked in {@code init()} because {@code fess_sso++.xml} registers every authenticator - * unconditionally -- including when {@code sso.type} selects another one. - */ - protected void validateConfiguration() { - final List missing = new ArrayList<>(); - if (StringUtil.isBlank(getTenant())) { - missing.add(ENTRAID_TENANT); - } - if (StringUtil.isBlank(getClientId())) { - missing.add(ENTRAID_CLIENT_ID); - } - if (StringUtil.isBlank(getClientSecret())) { - missing.add(ENTRAID_CLIENT_SECRET); - } - if (!missing.isEmpty()) { - throw new SsoLoginException("Entra ID is not configured. The following settings are empty: " + String.join(", ", missing)); - } - } - - /** - * Generates the Entra ID authorization URL for the authentication request. - * @param request The HTTP servlet request. - * @return The authorization URL to redirect the user to. - */ - protected String getAuthUrl(final HttpServletRequest request) { - // UUID.randomUUID is backed by SecureRandom and varies in 122 bits. The state is the - // only thing standing between a login and a forged callback (RFC 6749 section 10.12), and - // org.codelibs.core.net.UuidUtil, which this used to call, keeps the first 16 hex - // characters constant for the life of the JVM and varies under 32 bits per call. - final String state = UUID.randomUUID().toString(); - final String nonce = UUID.randomUUID().toString(); - storeStateInSession(request.getSession(), state, nonce); - - final String responseMode = getResponseMode(); - final String authUrl = getAuthorityUrl() + "oauth2/v2.0/authorize?response_type=code&scope=" + V2_SCOPES_ENCODED + "&response_mode=" - + responseMode + "&redirect_uri=" + URLEncoder.encode(getReplyUrl(request), Constants.UTF_8_CHARSET) + "&client_id=" - + getClientId() + "&state=" + state + "&nonce=" + nonce; - if (logger.isDebugEnabled()) { - logger.debug("redirect to: {}", authUrl); - } - return authUrl; - - } - - /** - * Stores state and nonce information in the HTTP session. - * @param session The HTTP session. - * @param state The OAuth2 state parameter. - * @param nonce The OpenID Connect nonce parameter. - */ - protected void storeStateInSession(final HttpSession session, final String state, final String nonce) { - final Map stateMap = getStateMap(session); - removeExpiredStates(stateMap); - removeOldestStates(stateMap, maxStates - 1); - final StateData stateData = new StateData(nonce, ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); - if (logger.isDebugEnabled()) { - logger.debug("Storing state in session: {}", stateData); - } - stateMap.put(state, stateData); - } - - /** - * Returns the per-session map of pending authorization attempts, creating it if needed. - * The map is concurrent, and the create is synchronized on the session, because a user can - * have several login attempts in flight at once -- a plain HashMap created twice loses the - * state one of them has to validate later. - * - * @param session The HTTP session. - * @return The state map held by the session. - */ - protected Map getStateMap(final HttpSession session) { - synchronized (session) { - @SuppressWarnings("unchecked") - final Map stateMap = (Map) session.getAttribute(STATES); - if (stateMap instanceof ConcurrentHashMap) { - return stateMap; - } - // Either absent, or a plain HashMap left by a session that predates this change. - final Map concurrentMap = new ConcurrentHashMap<>(); - if (stateMap != null) { - concurrentMap.putAll(stateMap); - } - session.setAttribute(STATES, concurrentMap); - return concurrentMap; - } - } - - /** - * Drops states that are older than the configured TTL. - * - * @param stateMap The state map to prune. - */ - protected void removeExpiredStates(final Map stateMap) { - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final long stateTtl = getStateTtl(); - stateMap.entrySet() - .stream() - .filter(e -> (now - e.getValue().getExpiration()) / 1000L > stateTtl) - .map(Map.Entry::getKey) - .collect(Collectors.toList()) - .forEach(s -> { - if (logger.isDebugEnabled()) { - logger.debug("Removing old state: {}", s); - } - stateMap.remove(s); - }); - } - - /** - * Drops the least recently created states until at most {@code limit} remain. Unfinished - * attempts never expire on their own before the TTL, so this is what bounds the map for a - * client that keeps starting logins. - * - * @param stateMap The state map to prune. - * @param limit The number of states to keep. - */ - protected void removeOldestStates(final Map stateMap, final int limit) { - if (stateMap.size() <= limit) { - return; - } - stateMap.entrySet() - .stream() - .sorted(Comparator.comparingLong(e -> e.getValue().getExpiration())) - .limit((long) stateMap.size() - limit) - .map(Map.Entry::getKey) - .collect(Collectors.toList()) - .forEach(s -> { - if (logger.isDebugEnabled()) { - logger.debug("Removing surplus state: {}", s); - } - stateMap.remove(s); - }); - } - - /** - * Sets the maximum number of pending authorization attempts kept per session. - * @param maxStates The maximum number of states. - */ - public void setMaxStates(final int maxStates) { - this.maxStates = maxStates; - } - - /** - * Processes authentication data from the OAuth2 callback. - * @param request The HTTP servlet request containing authentication data. - * @return The login credential or null if processing fails. - */ - protected LoginCredential processAuthenticationData(final HttpServletRequest request) { - final StringBuilder urlBuf = new StringBuilder(request.getRequestURL()); - final String queryStr = request.getQueryString(); - if (queryStr != null) { - urlBuf.append('?').append(queryStr); - } - - final Map> params = new HashMap<>(); - for (final Map.Entry e : request.getParameterMap().entrySet()) { - if (e.getValue().length > 0) { - params.put(e.getKey(), Arrays.asList(e.getValue())); - } - } - if (logger.isDebugEnabled()) { - logger.debug("process authentication: url: {}, params: {}", request.getRequestURL(), maskParams(params)); - } - - // validate that state in response equals to state in request - final StateData stateData = validateState(request.getSession(), params.containsKey(STATE) ? params.get(STATE).get(0) : null); - if (logger.isDebugEnabled()) { - logger.debug("Loading state: {}", stateData); - } - - final AuthenticationResponse authResponse = parseAuthenticationResponse(urlBuf.toString(), params); - if (authResponse instanceof final AuthenticationSuccessResponse oidcResponse) { - validateAuthRespMatchesCodeFlow(oidcResponse); - final IAuthenticationResult authData = getAccessToken(oidcResponse.getAuthorizationCode(), getReplyUrl(request)); - validateNonce(stateData, authData); - - return new EntraIdCredential(authData); - } - final AuthenticationErrorResponse oidcResponse = (AuthenticationErrorResponse) authResponse; - throw new SsoLoginException(String.format("Request for auth code failed: %s - %s", oidcResponse.getErrorObject().getCode(), - oidcResponse.getErrorObject().getDescription())); - } - - /** - * Parses the authentication response from Entra ID. - * @param url The response URL. - * @param params The response parameters. - * @return The parsed authentication response. - */ - protected AuthenticationResponse parseAuthenticationResponse(final String url, final Map> params) { - if (logger.isDebugEnabled()) { - logger.debug("Parse: {} : {}", maskQueryString(url), maskParams(params)); - } - try { - return AuthenticationResponseParser.parse(new URI(url), params); - } catch (final Exception e) { - throw new SsoLoginException("Failed to parse an authentication response.", e); - } - } - - /** - * Validates the nonce in the authentication result. - * @param stateData The stored state data containing the expected nonce. - * @param authData The authentication result containing the actual nonce. - */ - protected void validateNonce(final StateData stateData, final IAuthenticationResult authData) { - final String idToken = authData.idToken(); - if (logger.isDebugEnabled()) { - logger.debug("idToken={}", maskSecret(idToken)); - } - try { - final JWTClaimsSet claimsSet = JWTParser.parse(idToken).getJWTClaimsSet(); - if (claimsSet == null) { - throw new SsoStateException("could not validate nonce"); - } - - final String nonce = (String) claimsSet.getClaim("nonce"); - if (logger.isDebugEnabled()) { - logger.debug("nonce={}", nonce); - } - if (StringUtil.isEmpty(nonce) || !nonce.equals(stateData.getNonce())) { - throw new SsoStateException("could not validate nonce"); - } - } catch (final SsoLoginException e) { - throw e; - } catch (final Exception e) { - // Not an SsoStateException: this is only reachable once the authorization code was - // redeemed, so an unparsable or unreadable ID token is a fault worth a stack trace, - // not a callback someone sent us. - throw new SsoLoginException("could not validate nonce", e); - } - } - - /** - * Returns the shared MSAL4J client application. - * - *

Each {@link ConfidentialClientApplication} owns its own in-memory token cache, and - * {@code acquireTokenSilently} throws {@code NO_TOKEN_IN_CACHE} on a miss. Building one per - * call therefore made silent refresh impossible: the tokens acquired at login went into an - * instance that was thrown away immediately afterwards. One instance per authenticator keeps - * them reachable. MSAL4J documents the application as thread safe and meant to be reused. - * - *

The instance is rebuilt when the client id, secret or tenant changes, because all three - * are editable from the admin screen while Fess is running. - * - * @return The client application. - */ - protected ConfidentialClientApplication getClientApplication() { - final ClientApplicationHolder current = clientApplicationHolder; - final String key = buildClientApplicationKey(); - if (current != null && current.getKey().equals(key)) { - return current.getApplication(); - } - synchronized (this) { - // The four settings are read again, and the key recomputed, inside the monitor. They - // are four independent reads of mutable configuration, so a key built on the fast path - // can mix values from before and after an admin save; publishing an application built - // from that mixture would leave it in place indefinitely. - final String currentKey = buildClientApplicationKey(); - final ClientApplicationHolder holder = clientApplicationHolder; - if (holder != null && holder.getKey().equals(currentKey)) { - return holder.getApplication(); - } - final String clientId = getClientId(); - final String clientSecret = getClientSecret(); - final String authority = getAuthorityUrl(); - if (logger.isDebugEnabled()) { - logger.debug("Building a client application for authority={}", authority); - } - try { - final ConfidentialClientApplication application = ConfidentialClientApplication - .builder(clientId, com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(clientSecret)) - .authority(authority) - .build(); - clientApplicationHolder = - new ClientApplicationHolder(buildClientApplicationKey(clientId, clientSecret, authority), application); - return application; - } catch (final Exception e) { - throw new SsoLoginException("Failed to build an Entra ID client application.", e); - } - } - } - - /** - * Reads the configuration the client application depends on and reduces it to a key. - * - * @return The key. - */ - protected String buildClientApplicationKey() { - return buildClientApplicationKey(getClientId(), getClientSecret(), getAuthorityUrl()); - } - - /** - * Reduces the configuration the client application depends on to a key. - * - * @param clientId The client id. - * @param clientSecret The client secret. - * @param authority The authority URL. - * @return The key. - */ - protected String buildClientApplicationKey(final String clientId, final String clientSecret, final String authority) { - // The secret is reduced to a hash so the key can never carry it into a log or a heap dump - // label; a collision would only mean the application is not rebuilt after a secret change. - return authority + '\n' + clientId + '\n' + clientSecret.hashCode(); - } - - /** - * A client application and the configuration key it was built from, published together so a - * reader can never see one without the other. - */ - protected static final class ClientApplicationHolder { - private final String key; - private final ConfidentialClientApplication application; - - /** - * Constructs a holder. - * - * @param key The configuration key. - * @param application The application built from it. - */ - public ClientApplicationHolder(final String key, final ConfidentialClientApplication application) { - this.key = key; - this.application = application; - } - - /** - * Gets the configuration key. - * - * @return The key. - */ - public String getKey() { - return key; - } - - /** - * Gets the client application. - * - * @return The application. - */ - public ConfidentialClientApplication getApplication() { - return application; - } - } - - /** - * Obtains an access token using a refresh token. - * @param refreshToken The refresh token to use for token acquisition. - * @return The authentication result containing the access token. - */ - public IAuthenticationResult getAccessToken(final String refreshToken) { - final String authority = getAuthorityUrl(); - if (logger.isDebugEnabled()) { - logger.debug("refreshToken={}, authority={}", maskSecret(refreshToken), authority); - } - try { - final ConfidentialClientApplication app = getClientApplication(); - - final RefreshTokenParameters parameters = - RefreshTokenParameters.builder(Collections.singleton(GRAPH_DEFAULT_SCOPE), refreshToken).build(); - - final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS); - if (result == null) { - throw new SsoLoginException("authentication result was null"); - } - trackAccount(result); - return result; - } catch (final Exception e) { - throw new SsoLoginException("Failed to get a token.", e); - } - } - - /** - * Obtains an access token using an authorization code. - * @param authorizationCode The authorization code received from Entra ID. - * @param currentUri The current URI for the redirect. - * @return The authentication result containing the access token. - */ - protected IAuthenticationResult getAccessToken(final AuthorizationCode authorizationCode, final String currentUri) { - final String authority = getAuthorityUrl(); - final String authCode = authorizationCode.getValue(); - if (logger.isDebugEnabled()) { - logger.debug("authCode={}, authority={}, uri={}", maskSecret(authCode), authority, currentUri); - } - try { - final ConfidentialClientApplication app = getClientApplication(); - - final AuthorizationCodeParameters parameters = AuthorizationCodeParameters.builder(authCode, new URI(currentUri)) - .scopes(Collections.singleton(GRAPH_DEFAULT_SCOPE)) - .build(); - - final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS); - if (result == null) { - throw new SsoLoginException("authentication result was null"); - } - trackAccount(result); - return result; - } catch (final Exception e) { - throw new SsoLoginException("Failed to get a token.", e); - } - } - - /** - * Attempts to refresh tokens silently using the MSAL4J silent authentication flow. - * @param user The Entra ID user whose tokens need to be refreshed. - * @return The new authentication result, or null if silent refresh failed. - */ - public IAuthenticationResult refreshTokenSilently(final EntraIdCredential.EntraIdUser user) { - try { - final ConfidentialClientApplication app = getClientApplication(); - - final SilentParameters parameters = - SilentParameters.builder(Collections.singleton(GRAPH_DEFAULT_SCOPE), user.getAuthenticationResult().account()).build(); - - final IAuthenticationResult result = app.acquireTokenSilently(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS); - if (logger.isDebugEnabled()) { - logger.debug("Silent token acquisition successful"); - } - trackAccount(result); - return result; - } catch (final Exception e) { - if (logger.isDebugEnabled()) { - logger.debug("Silent token acquisition failed: {}", e.getMessage()); - } - return null; - } - } - - /** - * Validates that the authentication response matches the authorization code flow. - * @param oidcResponse The OpenID Connect authentication success response. - */ - protected void validateAuthRespMatchesCodeFlow(final AuthenticationSuccessResponse oidcResponse) { - if (oidcResponse.getIDToken() != null || oidcResponse.getAccessToken() != null || oidcResponse.getAuthorizationCode() == null) { - throw new SsoLoginException("unexpected set of artifacts received"); - } - } - - /** - * Validates the OAuth2 state parameter. - * @param session The HTTP session containing stored state data. - * @param state The state parameter to validate. - * @return The validated state data. - */ - protected StateData validateState(final HttpSession session, final String state) { - if (StringUtil.isNotEmpty(state)) { - final StateData stateDataInSession = removeStateFromSession(session, state); - if (stateDataInSession != null) { - return stateDataInSession; - } - } - throw new SsoStateException("could not validate state"); - } - - /** - * Removes and returns state data from the HTTP session. - * @param session The HTTP session. - * @param state The state parameter to remove. - * @return The removed state data or null if not found. - */ - protected StateData removeStateFromSession(final HttpSession session, final String state) { - final Map states = getStateMap(session); - removeExpiredStates(states); - final StateData stateData = states.remove(state); - if (stateData != null && logger.isDebugEnabled()) { - logger.debug("Restoring state from session: {}", stateData); - } - return stateData; - } - - /** - * Checks if the request contains authentication data from Entra ID. - * @param request The HTTP servlet request to check. - * @return True if authentication data is present, false otherwise. - */ - protected boolean containsAuthenticationData(final HttpServletRequest request) { - if (logger.isDebugEnabled()) { - logger.debug("HTTP Method: {}", request.getMethod()); - } - // The authorization response arrives as a GET in query mode and as a POST in form_post - // mode; both are accepted because entraid.response.mode selects between them, and a login - // already in flight when that setting changes still has to complete. - final String method = request.getMethod(); - if (!"GET".equalsIgnoreCase(method) && !"POST".equalsIgnoreCase(method)) { - return false; - } - final Map params = request.getParameterMap(); - if (logger.isDebugEnabled()) { - logger.debug("params={}", params.keySet()); - } - return params.containsKey(ERROR) || params.containsKey(ID_TOKEN) || params.containsKey(CODE); - } - - /** - * Applies the headers and timeouts every Microsoft Graph request needs. - * - * @param request The request to configure. - * @param accessToken The bearer token to authenticate with. - * @return The configured request. - */ - protected CurlRequest createGraphRequest(final CurlRequest request, final String accessToken) { - return request.header("Authorization", "Bearer " + accessToken) - .header("Accept", "application/json") - .timeout(graphConnectTimeout, graphReadTimeout); - } - - /** - * Sets the connection timeout for Microsoft Graph requests. - * @param graphConnectTimeout The timeout in milliseconds. - */ - public void setGraphConnectTimeout(final int graphConnectTimeout) { - this.graphConnectTimeout = graphConnectTimeout; - } - - /** - * Sets the read timeout for Microsoft Graph requests. - * @param graphReadTimeout The timeout in milliseconds. - */ - public void setGraphReadTimeout(final int graphReadTimeout) { - this.graphReadTimeout = graphReadTimeout; - } - - /** - * Updates the user's group and role membership information, walking direct memberships and - * their parent groups in one pass. This method itself runs synchronously -- {@link - * #scheduleUpdateMemberOf} is what keeps it off the login thread -- so tests can call it - * directly. - * - *

When Microsoft Graph does not answer with a membership list, a re-resolution keeps the - * memberships it resolved earlier rather than replacing them. A first resolution has none of - * those to keep, so it writes whatever was collected on top of the configured defaults the - * lists were seeded with, and marks the user {@link PermissionState#FAILED} rather than - * leaving the session with no memberships at all. - * - * @param user The Entra ID user to update. - */ - public void updateMemberOf(final EntraIdUser user) { - // Captured before anything below writes to the user: markResolutionCompleted later in this - // method changes what user.isResolutionCompleted() answers. - // - // Not `user.getGroupNames() == null`, which this used to be: the constructor seeds the - // configured defaults, so the memberships are never null and every resolution would take - // the re-resolution path -- keeping the defaults forever instead of the resolved groups. - final boolean firstResolution = !user.isResolutionCompleted(); - if (logger.isDebugEnabled()) { - logger.debug("[updateMemberOf] Starting for user: {}", user.getName()); - } - - final List groupList = new ArrayList<>(); - final List roleList = new ArrayList<>(); - final List groupIdsForParentLookup = new ArrayList<>(); - - final List defaultGroups = getDefaultGroupList(); - final List defaultRoles = getDefaultRoleList(); - groupList.addAll(defaultGroups); - roleList.addAll(defaultRoles); - - if (logger.isDebugEnabled()) { - logger.debug("[updateMemberOf] Default groups: {}, Default roles: {}", defaultGroups, defaultRoles); - } - - // Retrieve direct group/role memberships; group IDs are collected for the parent walk below. - final boolean resolved = processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, GRAPH_V1_URL + "/me/memberOf"); - - if (logger.isDebugEnabled()) { - logger.debug("[updateMemberOf] Direct groups retrieved. Total groups: {}, Total roles: {}, Group IDs for parent lookup: {}", - groupList.size(), roleList.size(), groupIdsForParentLookup.size()); - } - - if (!resolved) { - // Microsoft Graph did not answer with a membership list -- an expired token, a - // throttled tenant, a revoked permission. - if (!firstResolution) { - // Refresh path. Writing what we have would replace the memberships this user - // logged in with by the configured defaults alone, silently taking away their - // search permissions until some later call happens to succeed. - logger.warn("Failed to resolve the Entra ID memberships of {}. Keeping the ones already resolved.", user.getName()); - return; - } - // First resolution, so there is nothing to keep. Degrade rather than refuse: a - // throttled tenant, a Graph outage or a permission that was never granted would - // otherwise refuse every login in the tenant for as long as the condition lasts. The - // lists are seeded with the configured defaults before the lookup, so what is written - // below is always a superset of them, and Task 4 makes the shortfall visible. - logger.warn("Failed to resolve the Entra ID memberships of {}. Continuing with the memberships" - + " collected so far and the configured defaults.", user.getName()); - } - - // Every direct group is still walked after one of them fails: a partial parent set is worth - // more than none, so the failures are collected rather than short-circuited. What does end - // the walk early is maxConsecutiveGroupLookupFailures answers in a row that Graph did not - // give -- past that point the tenant is unreachable rather than one group being broken, - // and continuing only buys one request, one timeout and one stack trace per remaining - // group. Whatever was collected before that is still applied below. - boolean parentsResolved = true; - int walkedCount = 0; - int consecutiveFailures = 0; - for (final String groupId : groupIdsForParentLookup) { - ++walkedCount; - if (processParentGroup(user, groupList, roleList, groupId)) { - consecutiveFailures = 0; - continue; - } - parentsResolved = false; - if (isGraphThrottled()) { - // A lookup skipped for the backoff never reached Graph: it costs nothing, and the - // backoff already bounds the tenant. Counting it would end the walk -- and log the - // WARN below -- on every login for as long as the throttle lasts, for no saving. - continue; - } - if (++consecutiveFailures >= maxConsecutiveGroupLookupFailures) { - logger.warn( - "Stopped resolving the nested groups of {} after {} consecutive Microsoft Graph failures." - + " {} of {} direct groups were not walked.", - user.getName(), consecutiveFailures, groupIdsForParentLookup.size() - walkedCount, groupIdsForParentLookup.size()); - break; - } - } - - user.setGroups(groupList.stream().distinct().toArray(String[]::new)); - user.setRoles(roleList.stream().distinct().toArray(String[]::new)); - user.resetPermissions(); - - // No firstResolution guard: the only case that must not touch the state -- a re-resolution - // whose direct lookup Graph did not answer -- returned early above. What reaches here is - // either a resolution that succeeded, first or not, or one that fell short in the direct - // lookup or in the parent group walk. Guarding this would pin a stale FAILED on a user - // whose token renewal has since resolved their groups. - // - // The walk counts as much as the direct lookup: a Graph backoff is recorded on this - // authenticator for the whole tenant, so one user's 429 skips every parent lookup for up - // to MAX_GRAPH_THROTTLE_SECONDS while the direct lookup keeps answering. The users - // resolved in that window hold their direct groups alone -- fewer permissions than they - // should have, which is what FAILED is there to say. - user.setPermissionState(resolved && parentsResolved ? PermissionState.RESOLVED : PermissionState.FAILED); - - // Every path that reaches here has written the memberships, so the next resolution is a - // re-resolution and must keep them rather than fall back to the defaults alone. - user.markResolutionCompleted(); - - ComponentUtil.getActivityHelper().permissionChanged(OptionalThing.of(new FessUserBean(user))); - - if (logger.isDebugEnabled()) { - logger.debug("[updateMemberOf] Completed for user: {}", user.getName()); - } - } - - /** - * Puts the configured default groups and roles on a user that has just been constructed, so - * that they apply for the whole window before {@link #updateMemberOf} lands rather than only - * after it. - * - *

Nothing here reaches Microsoft Graph -- {@code entraid.default.groups} and - * {@code entraid.default.roles} are static configuration -- so it is safe on the login thread, - * which is the point: {@code SsoAction} redirects to the search page in the same request that - * schedules the resolution, and a user holding no groups at all sees a near-empty result set - * until it completes. - * - *

This does not mark the user as resolved: it is a seed, not a resolution, and - * {@link #updateMemberOf} must still treat the next run as the first one. - * - * @param user The Entra ID user to seed. - */ - public void applyDefaultMemberships(final EntraIdUser user) { - user.setGroups(getDefaultGroupList().stream().distinct().toArray(String[]::new)); - user.setRoles(getDefaultRoleList().stream().distinct().toArray(String[]::new)); - } - - /** - * Field names from {@code entraid.permission.fields} that Microsoft Graph does not answer with - * a string. Reported once each, because the alternative is one warning per group per login. - */ - protected final Set unusablePermissionFields = ConcurrentHashMap.newKeySet(); - - /** - * Reads one of the fields named by {@code entraid.permission.fields} out of a Microsoft Graph - * object as a permission value. - * - *

Only a string can be one: a permission is matched against the {@code role} field of a - * document, which holds strings. Graph answers with the type the directory schema gives the - * field, so naming {@code securityEnabled} or {@code groupTypes} yields a boolean or an array. - * Casting those threw {@link ClassCastException} out of the middle of the membership loop, - * where the surrounding {@code catch} turned one mistyped field name into "no groups at all" - * for every user in the tenant. Skipping the value keeps the rest of the memberships, and the - * warning says which field is at fault. - * - * @param source The Graph object to read from. - * @param name The configured field name. - * @return The value, or null when the field is absent or is not a string. - */ - protected String getPermissionFieldValue(final Map source, final String name) { - final Object value = source.get(name); - if (value == null || value instanceof String) { - return (String) value; - } - if (unusablePermissionFields.add(name)) { - logger.warn("entraid.permission.fields names {}, which Microsoft Graph answers with {} rather than a string. " - + "It cannot be a permission value and is ignored.", name, value.getClass().getSimpleName()); - } - return null; - } - - /** - * Adds a group or role name to the specified list. - * @param list The list to add the group or role name to. - * @param value The group or role name value. - * @param useDomainServices Whether to use domain services for group resolution. - */ - protected void addGroupOrRoleName(final List list, final String value, final boolean useDomainServices) { - list.add(value); - if (useDomainServices && value.indexOf('@') >= 0) { - final String[] values = value.split("@"); - if (values.length > 1) { - list.add(values[0]); - } - } - } - - /** - * Processes direct member-of information from Microsoft Graph API without parent group lookup. - * This method retrieves only direct group memberships and collects their group IDs, which - * {@link #updateMemberOf} then walks for parent groups in the same pass. - * @param user The Entra ID user. - * @param groupList The list to add group names to. - * @param roleList The list to add role names to. - * @param groupIdsForParentLookup The list to collect group IDs for later parent lookup. - * @param url The Microsoft Graph API URL. - * @return True if Microsoft Graph answered with a membership list, false if it reported an - * error or could not be read. When this is false the lists hold the configured - * defaults they were seeded with plus whatever was collected before the failure, and - * what {@link #updateMemberOf} does with them turns on whether a resolution has - * completed for this user before: a re-resolution discards them and keeps the - * memberships it resolved earlier, while a first resolution writes them. - */ - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - if (logger.isDebugEnabled()) { - logger.debug("[processDirectMemberOf] Fetching direct memberships from URL: {}", url); - } - try (CurlResponse response = createGraphRequest(Curl.get(url), user.getAuthenticationResult().accessToken()).execute()) { - // Before the body, for the same reason as in getMemberGroupIds: a throttled reply is - // not required to be JSON, and the parser throws CurlException when it is not. The - // lookup itself is still attempted while throttled -- a login has to try -- but - // recording the backoff here is what keeps the asynchronous parent group walk from - // hammering a Graph that already asked us to wait. - applyGraphThrottle(response); - final Map contentMap = response.getContent(SearchEngineCurl.jsonParser()); - if (logger.isDebugEnabled()) { - logger.debug("response={}", contentMap); - } - if (contentMap.containsKey("value")) { - @SuppressWarnings("unchecked") - final List> memberOfList = (List>) contentMap.get("value"); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String[] names = fessConfig.getEntraIdPermissionFields(); - final boolean useDomainServices = fessConfig.isEntraIdUseDomainServices(); - for (final Map memberOf : memberOfList) { - if (logger.isDebugEnabled()) { - logger.debug("member={}", memberOf); - } - String memberType = (String) memberOf.get("@odata.type"); - if (memberType == null) { - logger.warn("@odata.type is null: {}", memberOf); - continue; - } - memberType = memberType.toLowerCase(Locale.ENGLISH); - final String id = (String) memberOf.get("id"); - if (StringUtil.isNotBlank(id)) { - if (memberType.contains("group")) { - groupList.add(id); - // Collect group ID for parent lookup (deferred) - groupIdsForParentLookup.add(id); - if (logger.isDebugEnabled()) { - logger.debug("[processDirectMemberOf] Added group ID: {} (will lookup parent groups later)", id); - } - } else if (memberType.contains("role")) { - roleList.add(id); - if (logger.isDebugEnabled()) { - logger.debug("[processDirectMemberOf] Added role ID: {}", id); - } - } else { - if (logger.isDebugEnabled()) { - logger.debug("[processDirectMemberOf] Unknown @odata.type: {}, treating as group", memberOf); - } - groupList.add(id); - groupIdsForParentLookup.add(id); - } - } else { - logger.warn("id is empty: {}", memberOf); - } - for (final String name : names) { - final String value = getPermissionFieldValue(memberOf, name); - if (StringUtil.isNotBlank(value)) { - if (logger.isDebugEnabled()) { - logger.debug("{} is a member of {}", name, value); - } - addGroupOrRoleName(memberType.contains("role") ? roleList : groupList, value, useDomainServices); - } else if (logger.isDebugEnabled()) { - logger.debug("{} is empty: {}", name, memberOf); - } - } - } - final String nextLink = (String) contentMap.get("@odata.nextLink"); - if (StringUtil.isNotBlank(nextLink)) { - return processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, nextLink); - } - return true; - } - if (contentMap.containsKey("error")) { - logger.warn("Failed to access groups/roles: {}", contentMap); - } else { - logger.warn("Unexpected response while accessing groups/roles: {}", contentMap); - } - return false; - } catch (final IOException | RuntimeException e) { - // Every unchecked failure has to take this path too, not just curl4j's CurlException: - // a body whose "value" is not an array of objects throws ClassCastException from the - // cast above, which used to escape updateMemberOf and the EntraIdUser constructor and - // land on the generic error page rather than on the controlled outcome the caller - // chooses. Nothing thrown from inside this method has to propagate to the caller. - logger.warn("Failed to access groups/roles in Entra ID.", e); - return false; - } - } - - /** - * Runs {@link #updateMemberOf} on a background thread. - * - *

The Microsoft Graph calls behind it -- the direct membership lookup and the parent group - * walk, one call per direct group -- used to be split across the login thread and a second - * scheduled task. One task keeps the login off Graph altogether and writes the groups, the - * roles and the permission reset once at the end, instead of publishing the direct groups and - * then overwriting them. - * - * @param user The Entra ID user to resolve. - */ - public void scheduleUpdateMemberOf(final EntraIdUser user) { - TimeoutManager.getInstance().addTimeoutTarget(() -> { - final long startTime = System.currentTimeMillis(); - try { - updateMemberOf(user); - } catch (final Exception e) { - // A backstop: updateMemberOf contains every Graph failure itself, so reaching here - // means something unforeseen -- possibly after the memberships were already - // resolved and written correctly, e.g. a throw from the permissionChanged audit - // call below them. So this does not claim the resolution itself failed. What it - // does guard is PENDING: a throw between the setGroups/setRoles write and the - // setPermissionState write would otherwise leave the user PENDING forever, which - // is indistinguishable from never having been resolved at all. A re-resolution's - // state is RESOLVED (or FAILED), not PENDING, so this leaves it untouched. - logger.warn("Unexpected error while resolving the Entra ID memberships of {} after {}ms.", user.getName(), - System.currentTimeMillis() - startTime, e); - if (user.getPermissionState() == PermissionState.PENDING) { - user.setPermissionState(PermissionState.FAILED); - } - } - }, 0, false); - } - - /** - * Processes parent group information for nested groups. - * @param user The Entra ID user. - * @param groupList The list to add group names to. - * @param roleList The list to add role names to. - * @param id The group ID to process. - * @return True if the walk completed without a Microsoft Graph failure. See - * {@link #processParentGroup(EntraIdUser, List, List, String, int)}. - */ - protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List roleList, - final String id) { - return processParentGroup(user, groupList, roleList, id, 0); - } - - /** - * Processes parent group information for nested groups with depth tracking. - * @param user The Entra ID user. - * @param groupList The list to add group names to. - * @param roleList The list to add role names to. - * @param id The group ID to process. - * @param depth The current recursion depth. - * @return True if the walk completed without a Microsoft Graph failure, so that - * {@link #updateMemberOf} can tell a user who holds all their parent groups from one - * who silently holds only some of them. The configured depth bound is not a failure -- - * it is where the walk is meant to stop -- and neither is a group that genuinely has - * no parents. - */ - protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List roleList, final String id, - final int depth) { - if (logger.isDebugEnabled()) { - logger.debug("[processParentGroup] Processing parent groups for id: {}, depth: {}/{}", id, depth, maxGroupDepth); - } - if (depth >= maxGroupDepth) { - if (logger.isDebugEnabled()) { - logger.debug("[processParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id); - } - return true; - } - final AtomicBoolean failed = new AtomicBoolean(); - final Pair groupsAndRoles = getParentGroup(user, id, depth, failed); - Collections.addAll(groupList, groupsAndRoles.getFirst()); - Collections.addAll(roleList, groupsAndRoles.getSecond()); - if (logger.isDebugEnabled()) { - logger.debug("[processParentGroup] Completed for id: {}, depth: {}, added groups: {}, added roles: {}, failed: {}", id, depth, - groupsAndRoles.getFirst().length, groupsAndRoles.getSecond().length, failed.get()); - } - return !failed.get(); - } - - /** - * Retrieves parent group information for the specified group ID with depth tracking, for a - * caller that has nothing to report a failure to. - * @param user The Entra ID user. - * @param id The group ID to get parent information for. - * @param depth The current recursion depth. - * @return A pair containing group names and role names. - */ - protected Pair getParentGroup(final EntraIdUser user, final String id, final int depth) { - return getParentGroup(user, id, depth, new AtomicBoolean()); - } - - /** - * Retrieves parent group information for the specified group ID with depth tracking. - * - *

An empty result is not by itself an answer: it is also what a skipped or failed lookup - * returns. {@code failed} is what tells the two apart, so that a user missing their parent - * groups is not reported as fully resolved. - * - * @param user The Entra ID user. - * @param id The group ID to get parent information for. - * @param depth The current recursion depth. - * @param failed Set to true when Microsoft Graph could not be asked, or answered with a - * failure, anywhere in this walk. Never cleared, so one flag can be passed down the - * recursion and read once at the top. - * @return A pair containing group names and role names. - */ - protected Pair getParentGroup(final EntraIdUser user, final String id, final int depth, - final AtomicBoolean failed) { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Getting parent groups for id: {}, depth: {}", id, depth); - } - if (depth >= maxGroupDepth) { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id); - } - return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS); - } - // Check if cached - final String cacheKey = buildGroupCacheKey(id); - final Pair cachedResult = groupCache.getIfPresent(cacheKey); - if (cachedResult != null) { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Cache HIT for id: {}, groups: {}, roles: {}", id, cachedResult.getFirst().length, - cachedResult.getSecond().length); - } - return cachedResult; - } - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Cache MISS for id: {}, fetching from API", id); - } - if (isGraphThrottled()) { - // Microsoft Graph asked us to back off, so walking it would fail once per group on - // every login until the tenant recovered. The empty pair is deliberately not written - // to groupCache: caching it would keep the parent group permissions away for the whole - // cache TTL even once the throttle had lapsed, which is what #3223 removed. - // - // Skipped, not answered: the backoff is recorded for the whole tenant, so without this - // flag one user's 429 would report every login in the next hour as fully resolved - // while none of them held a single parent group. - failed.set(true); - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Skipping the lookup for id {} while Microsoft Graph is throttling.", id); - } - return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS); - } - try { - return groupCache.get(cacheKey, () -> loadParentGroup(user, id, depth, failed)); - } catch (final ExecutionException | UncheckedExecutionException e) { - failed.set(true); - // A loader that throws leaves nothing in the cache, which is the point: a throttled or - // briefly unreachable Graph must not pin an empty result for the whole cache TTL. - // UncheckedExecutionException matters because the Graph JSON parser throws - // CurlException, a RuntimeException, on a non-JSON error body. - if (isGraphThrottled()) { - // The reason is already stated by the single WARN that set the throttle; a stack - // trace per group would bury it. - logger.warn("Failed to process group cache for id {} while Microsoft Graph is throttling: {}", id, e.getMessage()); - } else { - logger.warn("Failed to process group cache for id: {}", id, e); - } - return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS); - } - } - - /** - * The key {@link #groupCache} stores a parent group lookup under. - * - *

What is cached is not the raw Graph answer: {@link #processGroup} has already turned it - * into the permission values that {@code entraid.permission.fields} selects. Keying it by - * group id alone meant a change to that setting was ignored for the rest of the cache TTL, and - * only for nested groups -- direct memberships are read from Graph on every login, so they - * picked the new setting up immediately. A user was then granted permissions derived from the - * new setting for the groups they belong to directly and from the old one for the groups above - * them. - * - *

Narrowing the setting is the case that matters. The documentation warns that - * {@code displayName} is neither domain-qualified nor unique and can match documents it should - * not; removing it once that has happened left every nested group granting it for up to ten - * more minutes. - * - * @param id The group id. - * @return The cache key. - */ - protected String buildGroupCacheKey(final String id) { - return id + '\n' + String.join(",", ComponentUtil.getFessConfig().getEntraIdPermissionFields()); - } - - /** - * Returns whether Microsoft Graph is still inside the backoff a throttled response asked for. - * - * @return True while the parent group walk has to be skipped. - */ - protected boolean isGraphThrottled() { - final long until = graphThrottledUntil; - // The clock is read only once a throttle has actually been recorded, so the ordinary path - // does not depend on the system helper at all. - return until > 0L && ComponentUtil.getSystemHelper().getCurrentTimeAsLong() < until; - } - - /** - * Records the backoff a throttled Microsoft Graph response asked for, if it is one. - * - *

Nothing is cached in response to it. 15.7 cached an empty result for the cache TTL, and - * #3223 removed that because it silently took the parent group permissions away for ten - * minutes. An explicit backoff does the job the negative cache was reaching for -- the next - * logins skip the walk instead of re-issuing one failing request, and one stack trace, per - * group -- without outliving the condition that caused it. - * - * @param response The response to inspect. - */ - protected void applyGraphThrottle(final CurlResponse response) { - // curl4j does not throw on a non-2xx response, it hands back the error stream, so the - // status code is the only place a 429 is visible. - final int statusCode = response.getHttpStatusCode(); - if (statusCode != HTTP_TOO_MANY_REQUESTS && statusCode != HTTP_SERVICE_UNAVAILABLE) { - return; - } - final long seconds = parseRetryAfterSeconds(response.getHeaderValue("Retry-After")); - final long until = ComponentUtil.getSystemHelper().getCurrentTimeAsLong() + seconds * 1000L; - if (until > graphThrottledUntil) { - graphThrottledUntil = until; - // One line per throttling episode: every group after this one short-circuits in - // getParentGroup without reaching Graph, so nothing repeats it per group. - logger.warn("Microsoft Graph returned {} for a group membership lookup." - + " Nested groups are not resolved for the next {} seconds.", statusCode, seconds); - } - } - - /** - * Reads a {@code Retry-After} header as a number of seconds. - * - * @param value The header value, which may be null. - * @return The backoff in seconds, always positive and bounded by - * {@link #MAX_GRAPH_THROTTLE_SECONDS}. - */ - protected long parseRetryAfterSeconds(final String value) { - if (StringUtil.isNotBlank(value)) { - try { - final long seconds = Long.parseLong(value.trim()); - if (seconds > 0L) { - return Math.min(seconds, MAX_GRAPH_THROTTLE_SECONDS); - } - } catch (final NumberFormatException e) { - // RFC 9110 also allows an HTTP-date here. Microsoft Graph sends delay-seconds, so - // rather than parse a format we never see, fall back to the default. - if (logger.isDebugEnabled()) { - logger.debug("Retry-After is not a number of seconds: {}", value); - } - } - } - return DEFAULT_GRAPH_THROTTLE_SECONDS; - } - - /** - * Walks the parent groups of the specified group. A failure of the {@code getMemberGroups} - * lookup itself is thrown rather than turned into an empty result, so a caller that caches - * this never stores a transient failure. A failure of one of the per-group reads underneath it - * cannot be thrown -- the groups that did answer are worth keeping -- so it is recorded in - * {@code failed} instead. - * - * @param user The Entra ID user. - * @param id The group ID to get parent information for. - * @param depth The current recursion depth. - * @param failed Set to true when a lookup underneath this one could not be made or failed. - * See {@link #getParentGroup(EntraIdUser, String, int, AtomicBoolean)}. - * @return A pair containing group names and role names. - * @throws IOException If Microsoft Graph could not be reached or returned an error. - */ - protected Pair loadParentGroup(final EntraIdUser user, final String id, final int depth, final AtomicBoolean failed) - throws IOException { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Loading parent groups for id: {}", id); - } - final List groupList = new ArrayList<>(); - final List roleList = new ArrayList<>(); - for (final String value : getMemberGroupIds(user, id)) { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Processing parent group id: {} for group: {}", value, id); - } - if (!processGroup(user, groupList, roleList, value)) { - // The group is dropped entirely -- processGroup adds nothing at all when the read - // fails -- so the user ends up without a parent group they are a member of. - failed.set(true); - } - if (!groupList.contains(value) && !roleList.contains(value)) { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Recursively getting parent groups for: {}", value); - } - final Pair groupsAndRoles = getParentGroup(user, value, depth + 1, failed); - Collections.addAll(groupList, groupsAndRoles.getFirst()); - Collections.addAll(roleList, groupsAndRoles.getSecond()); - } - } - final Pair result = - new Pair<>(groupList.stream().distinct().toArray(String[]::new), roleList.stream().distinct().toArray(String[]::new)); - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Result for id {}: {} groups, {} roles", id, result.getFirst().length, result.getSecond().length); - } - return result; - } - - /** - * Asks Microsoft Graph which groups the specified group is a member of. - * - *

Two error codes are real answers rather than failures and come back as an empty array so - * that the caller can cache them: {@code Request_ResourceNotFound}, because the group does not - * exist, and {@code Authorization_RequestDenied}, because a Graph permission that was never - * granted will not appear within the cache TTL -- throwing on it left nothing cached and made - * every login re-issue one failing request, and one stack trace, per group. Everything else is - * thrown, so a transient failure is never mistaken for "this group has no parents". - * - *

A throttled reply is recorded by {@link #applyGraphThrottle} before the body is looked - * at, so the groups after this one skip the walk instead of each producing their own failure. - * - * @param user The Entra ID user. - * @param id The group ID to get parent information for. - * @return The parent group IDs, never null. - * @throws IOException If Microsoft Graph could not be reached or returned an error. - */ - protected String[] getMemberGroupIds(final EntraIdUser user, final String id) throws IOException { - return getMemberGroupIds(user, id, GRAPH_V1_URL + "/groups/" + id + "/getMemberGroups"); - } - - /** - * Asks Microsoft Graph, at the specified URL, which groups the specified group is a member of. - * The URL is a parameter so that this, like {@link #processDirectMemberOf} and - * {@link #processGroup}, can be pointed at a stub rather than at Microsoft Graph. - * - * @param user The Entra ID user. - * @param id The group ID to get parent information for. - * @param url The Microsoft Graph URL to post the request to. - * @return The parent group IDs, never null. - * @throws IOException If Microsoft Graph could not be reached or returned an error. - */ - protected String[] getMemberGroupIds(final EntraIdUser user, final String id, final String url) throws IOException { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Calling API: {}", url); - } - try (CurlResponse response = - createGraphRequest(Curl.post(url), user.getAuthenticationResult().accessToken()).header("Content-type", "application/json") - .body("{\"securityEnabledOnly\":false}") - .execute()) { - // Before the body: a throttled reply is not required to be JSON, and the parser throws - // CurlException when it is not. - applyGraphThrottle(response); - final Map contentMap = response.getContent(SearchEngineCurl.jsonParser()); - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Response for id {}: {}", id, contentMap); - } - return toMemberGroupIds(contentMap, id); - } - } - - /** - * Classifies a {@code getMemberGroups} response body. See {@link #getMemberGroupIds} for which - * error codes count as an answer and which are failures. - * - * @param contentMap The parsed response body. - * @param id The group ID the response is for. - * @return The parent group IDs, never null. - * @throws IOException If the body reports a failure rather than an answer. - */ - protected String[] toMemberGroupIds(final Map contentMap, final String id) throws IOException { - if (contentMap.containsKey("value")) { - final String[] values = DocumentUtil.getValue(contentMap, "value", String[].class); - return values != null ? values : StringUtil.EMPTY_STRINGS; - } - if (contentMap.containsKey("error")) { - if (contentMap.get("error") instanceof final Map errorMap) { - final Object code = errorMap.get("code"); - if ("Request_ResourceNotFound".equals(code)) { - if (logger.isDebugEnabled()) { - logger.debug("[getParentGroup] Resource not found for id {}: {}", id, contentMap); - } - return StringUtil.EMPTY_STRINGS; - } - if (PERMISSION_DENIED_ERROR_CODE.equals(code)) { - logger.warn("Not allowed to read the parent groups of {}. Grant the Entra ID application" - + " GroupMember.Read.All to resolve nested groups. {}", id, contentMap); - return StringUtil.EMPTY_STRINGS; - } - } - throw new IOException("Failed to access parent groups for id " + id + ": " + contentMap); - } - return StringUtil.EMPTY_STRINGS; - } - - /** - * Processes individual group information. - * @param user The Entra ID user. - * @param groupList The list to add group names to. - * @param roleList The list to add role names to. - * @param id The group ID to process. - * @return True if Microsoft Graph could be read. See - * {@link #processGroup(EntraIdUser, List, List, String, String)}. - */ - protected boolean processGroup(final EntraIdUser user, final List groupList, final List roleList, final String id) { - return processGroup(user, groupList, roleList, id, GRAPH_V1_URL + "/groups/" + id); - } - - /** - * Processes individual group information read from the specified URL. The URL is a parameter - * so that this, like {@link #processDirectMemberOf}, can be pointed at a stub rather than at - * Microsoft Graph. - * - * @param user The Entra ID user. - * @param groupList The list to add group names to. - * @param roleList The list to add role names to. - * @param id The group ID to process. - * @param url The Microsoft Graph URL to read the group from. - * @return True if Microsoft Graph could be read. False means the group was dropped altogether - * -- nothing is added on that path -- so the caller has to know rather than take the - * shorter list for the answer. A body that reports an error is still a read: the group - * id is kept, only the names configured by {@code entraid.permission.fields} are not. - */ - protected boolean processGroup(final EntraIdUser user, final List groupList, final List roleList, final String id, - final String url) { - if (logger.isDebugEnabled()) { - logger.debug("[processGroup] Processing group info for id: {} from url: {}", id, url); - } - try (CurlResponse response = createGraphRequest(Curl.get(url), user.getAuthenticationResult().accessToken()).execute()) { - // Before the body, for the same reason as in getMemberGroupIds and - // processDirectMemberOf: a throttled reply is not required to be JSON, and the parser - // throws CurlException when it is not. This was the one Graph call in the class that - // did not record the backoff, and it is the one most likely to meet a 429 first: the - // parent group walk calls it once per member id, whereas getMemberGroupIds is called - // once per group. Worse, a 429 whose body *is* JSON leaves the walk believing it had - // an answer -- groupList.add(id) below runs on every non-throwing path -- so nothing - // else on that path ever reached Graph to notice the throttling. - applyGraphThrottle(response); - final Map contentMap = response.getContent(SearchEngineCurl.jsonParser()); - if (logger.isDebugEnabled()) { - logger.debug("[processGroup] Response for id {}: {}", id, contentMap); - } - groupList.add(id); - if (contentMap.containsKey("error")) { - logger.warn("Failed to access group info: {}", contentMap); - } else { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String[] names = fessConfig.getEntraIdPermissionFields(); - final int initialSize = groupList.size(); - for (final String name : names) { - final String value = getPermissionFieldValue(contentMap, name); - if (StringUtil.isNotBlank(value)) { - groupList.add(value); - if (logger.isDebugEnabled()) { - logger.debug("[processGroup] Added {} value: {} for group id: {}", name, value, id); - } - } else if (logger.isDebugEnabled()) { - logger.debug("[processGroup] {} is empty for group id: {}", name, id); - } - } - if (logger.isDebugEnabled()) { - logger.debug("[processGroup] Completed for id: {}, added {} entries", id, groupList.size() - initialSize); - } - } - return true; - } catch (final IOException | CurlException e) { - // See processDirectMemberOf: curl4j's transport failure is the unchecked CurlException. - logger.warn("Failed to access groups/roles in Entra ID for id: {}", id, e); - return false; - } - } - - /** - * Reads an Entra ID setting, preferring the {@code entraid.*} key and falling back to the - * legacy {@code aad.*} key. - * - *

{@code getSystemProperty} only substitutes the default when a key is absent, so a key that - * is present but empty would otherwise arrive at the caller as {@code ""}. Both keys are - * therefore tested with {@link StringUtil#isBlank(String)} and the default is returned when - * neither holds a value. - * - * @param key The {@code entraid.*} configuration key. - * @param legacyKey The legacy {@code aad.*} configuration key. - * @param defaultValue The value to use when neither key holds a value. - * @return The configured value, or defaultValue. - */ - protected String getEntraIdProperty(final String key, final String legacyKey, final String defaultValue) { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String value = fessConfig.getSystemProperty(key); - if (StringUtil.isNotBlank(value)) { - return value; - } - final String legacyValue = fessConfig.getSystemProperty(legacyKey); - return StringUtil.isBlank(legacyValue) ? defaultValue : legacyValue; - } - - /** - * Reads a comma-separated Entra ID setting as a list of trimmed, non-blank values. - * - * @param key The {@code entraid.*} configuration key. - * @param legacyKey The legacy {@code aad.*} configuration key. - * @return The configured values, or an empty list when neither key holds a value. - */ - protected List getDefaultList(final String key, final String legacyKey) { - final String value = getEntraIdProperty(key, legacyKey, StringUtil.EMPTY); - if (StringUtil.isBlank(value)) { - return Collections.emptyList(); - } - return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.toList())); - } - - /** - * Gets the default group list for users. - * Uses new entraid.default.groups key with fallback to legacy aad.default.groups. - * @return The default group list. - */ - protected List getDefaultGroupList() { - return getDefaultList(ENTRAID_DEFAULT_GROUPS, AAD_DEFAULT_GROUPS); - } - - /** - * Gets the default role list for users. - * Uses new entraid.default.roles key with fallback to legacy aad.default.roles. - * @return The default role list. - */ - protected List getDefaultRoleList() { - return getDefaultList(ENTRAID_DEFAULT_ROLES, AAD_DEFAULT_ROLES); - } - - /** - * Represents state data stored during the OAuth2 authentication flow. - */ - protected static class StateData { - private final String nonce; - private final long expiration; - - /** - * Constructs StateData with nonce and expiration. - * @param nonce The nonce value. - * @param expiration The expiration timestamp. - */ - public StateData(final String nonce, final long expiration) { - this.nonce = nonce; - this.expiration = expiration; - } - - /** - * Gets the nonce value. - * @return The nonce. - */ - public String getNonce() { - return nonce; - } - - /** - * Gets the expiration timestamp. - * @return The expiration timestamp. - */ - public long getExpiration() { - return expiration; - } - - @Override - public String toString() { - return "StateData [nonce=" + nonce + ", expiration=" + expiration + "]"; - } - } - - /** - * Gets the Entra ID client ID from configuration. - * Uses new entraid.client.id key with fallback to legacy aad.client.id. - * @return The client ID. - */ - protected String getClientId() { - return getEntraIdProperty(ENTRAID_CLIENT_ID, AAD_CLIENT_ID, StringUtil.EMPTY); - } - - /** - * Gets the Entra ID client secret from configuration. - * Uses new entraid.client.secret key with fallback to legacy aad.client.secret. - * @return The client secret. - */ - protected String getClientSecret() { - return getEntraIdProperty(ENTRAID_CLIENT_SECRET, AAD_CLIENT_SECRET, StringUtil.EMPTY); - } - - /** - * Gets the Entra ID tenant ID from configuration. - * Uses new entraid.tenant key with fallback to legacy aad.tenant. - * @return The tenant ID. - */ - protected String getTenant() { - return getEntraIdProperty(ENTRAID_TENANT, AAD_TENANT, StringUtil.EMPTY); - } - - /** - * Gets the Entra ID authority URL from configuration. - * Uses new entraid.authority key with fallback to legacy aad.authority. - * @return The authority URL. - */ - protected String getAuthority() { - return getEntraIdProperty(ENTRAID_AUTHORITY, AAD_AUTHORITY, DEFAULT_AUTHORITY); - } - - /** - * Builds the tenant's authority URL, the prefix every Entra ID endpoint is hung off. - * - *

The authority and the tenant are joined with exactly one {@code /}. This used to be a - * raw concatenation, and the only guard on {@link #getAuthority()} maps a blank value - * onto {@link #DEFAULT_AUTHORITY}, which already carries a trailing slash -- a non-blank value - * without one was passed through untouched. So - * {@code entraid.authority=https://login.microsoftonline.com} plus - * {@code entraid.tenant=contoso.onmicrosoft.com} produced - * {@code https://login.microsoftonline.comcontoso.onmicrosoft.com/}: the host and the tenant - * fuse into a single bogus hostname, the browser gets NXDOMAIN, and because the authorization - * URL is only logged at debug level nothing points back at the setting. - * {@code https://login.microsoftonline.com} is how the endpoint is written wherever it is - * documented, so omitting the trailing slash is the expected mistake rather than an exotic one. - * - *

A tenant that already starts with a slash joins correctly against a slashless authority, - * so the separator is inserted only when neither side supplies one and a doubled separator is - * collapsed; a naive "always append a slash to the authority" fix would break that working - * configuration. Nothing is lowercased or trimmed, and the value still carries at least one - * path segment, which msal4j's {@code Authority.detectAuthorityType} requires. - * - * @return The authority URL, with a trailing slash. - */ - protected String getAuthorityUrl() { - final String authority = getAuthority(); - final String tenant = getTenant(); - if (StringUtil.isEmpty(tenant)) { - // Left byte-identical to what a blank tenant produced before. validateConfiguration - // refuses one on the login path, and the doubled slash it leaves behind is quoted in - // that method's own javadoc as the symptom an unconfigured server shows. - return authority + "/"; - } - final String base = authority.endsWith("/") ? authority : authority + "/"; - return base + (tenant.startsWith("/") ? tenant.substring(1) : tenant) + "/"; - } - - /** - * Gets the state time-to-live from configuration. - * Uses new entraid.state.ttl key with fallback to legacy aad.state.ttl. - * @return The state TTL in seconds. removeExpiredStates compares it against an elapsed time - * that has already been divided by 1000. - */ - protected long getStateTtl() { - final String value = getEntraIdProperty(ENTRAID_STATE_TTL, AAD_STATE_TTL, DEFAULT_STATE_TTL); - final long ttl; - try { - ttl = Long.parseLong(value.trim()); - } catch (final NumberFormatException e) { - logger.warn("Invalid {}: {}. Using {} seconds.", ENTRAID_STATE_TTL, value, DEFAULT_STATE_TTL); - return Long.parseLong(DEFAULT_STATE_TTL); - } - if (ttl <= 0L) { - // removeExpiredStates drops a state once (now - created) / 1000 exceeds this value, so - // a non-positive TTL expires every login attempt before the user can finish signing in - // at Microsoft. The callback then reports "could not validate state", which names - // neither this setting nor the reason, and no login on the server can ever succeed. - logger.warn("Invalid {}: {}. A login cannot outlive a state that expires immediately. Using {} seconds.", ENTRAID_STATE_TTL, - value, DEFAULT_STATE_TTL); - return Long.parseLong(DEFAULT_STATE_TTL); - } - return ttl; - } - - /** - * Gets the reply URL for Entra ID authentication. - * Uses new entraid.reply.url key with fallback to legacy aad.reply.url. - * @param request The HTTP servlet request. - * @return The reply URL. - */ - protected String getReplyUrl(final HttpServletRequest request) { - final String value = getEntraIdProperty(ENTRAID_REPLY_URL, AAD_REPLY_URL, StringUtil.EMPTY); - return StringUtil.isNotBlank(value) ? value : request.getRequestURL().toString(); - } - - /** - * Gets the OAuth2 response mode to ask the authorization endpoint for. - * - *

Defaults to {@code query}. Fess ships {@code tomcat.sameSiteCookies = lax}, and a Lax - * cookie is not sent on the cross-site POST that {@code form_post} produces, so a form_post - * callback arrives without JSESSIONID and the login loops. A deployment that sets - * {@code tomcat.sameSiteCookies = none} can select {@code form_post} to keep the - * authorization code out of the callback URL, and therefore out of browser history and any - * front-end proxy log. - * - * @return Either {@code query} or {@code form_post}. - */ - protected String getResponseMode() { - final String value = getEntraIdProperty(ENTRAID_RESPONSE_MODE, AAD_RESPONSE_MODE, RESPONSE_MODE_QUERY).trim(); - if (RESPONSE_MODE_QUERY.equals(value) || RESPONSE_MODE_FORM_POST.equals(value)) { - return value; - } - logger.warn("Invalid {}: {}. Using {}.", ENTRAID_RESPONSE_MODE, value, RESPONSE_MODE_QUERY); - return RESPONSE_MODE_QUERY; - } - - @Override - public void resolveCredential(final LoginCredentialResolver resolver) { - resolver.resolve(EntraIdCredential.class, credential -> OptionalEntity.of(credential.getUser())); - } - - /** - * Sets the token acquisition timeout. - * @param acquisitionTimeout The timeout in milliseconds. - */ - public void setAcquisitionTimeout(final long acquisitionTimeout) { - this.acquisitionTimeout = acquisitionTimeout; - } - - /** - * Sets the group cache expiry time. - * @param groupCacheExpiry The cache expiry time in seconds. - */ - public void setGroupCacheExpiry(final long groupCacheExpiry) { - this.groupCacheExpiry = groupCacheExpiry; - } - - /** - * Sets the maximum number of groups kept in the parent group cache. - * @param maxGroupCacheSize The maximum number of cached groups. - */ - public void setMaxGroupCacheSize(final int maxGroupCacheSize) { - this.maxGroupCacheSize = maxGroupCacheSize; - } - - /** - * Sets the maximum group depth for nested group processing. - * @param maxGroupDepth The maximum depth for nested groups. - */ - public void setMaxGroupDepth(final int maxGroupDepth) { - this.maxGroupDepth = maxGroupDepth; - } - - /** - * Sets how many consecutive unanswered parent group lookups end the walk. - * @param maxConsecutiveGroupLookupFailures The maximum number of consecutive failures. - */ - public void setMaxConsecutiveGroupLookupFailures(final int maxConsecutiveGroupLookupFailures) { - this.maxConsecutiveGroupLookupFailures = maxConsecutiveGroupLookupFailures; - } - - @Override - public String logout(final FessUserBean user) { - // The client application is shared for the whole server so that its token cache survives - // between a login and its refresh. MSAL4J's TokenCache is a set of unbounded LinkedHashMaps - // with no eviction, and removeAccount() is the only way anything leaves it, so a user who - // logs out has to be dropped explicitly or they stay resident until the JVM restarts. - if (user.getFessUser() instanceof final EntraIdUser entraIdUser) { - final IAuthenticationResult authResult = entraIdUser.getAuthenticationResult(); - if (authResult != null && authResult.account() != null) { - removeAccount(authResult.account()); - } - } - // Null keeps the existing behaviour: Fess does not sign the user out at Entra ID. - return null; - } - - /** - * Records that an acquisition put tokens in the shared cache, and evicts the accounts that - * pushes past {@link #maxCachedAccounts}. - * - *

The eviction runs outside the monitor: {@link #removeAccount} joins on MSAL4J's future, - * and holding the map while it does that would put every other acquisition behind it. - * - * @param result The acquisition result, which may be null. - */ - protected void trackAccount(final IAuthenticationResult result) { - if (result == null || result.account() == null || StringUtil.isBlank(result.account().homeAccountId())) { - return; - } - final List evicted = new ArrayList<>(); - synchronized (cachedAccounts) { - cachedAccounts.put(result.account().homeAccountId(), result.account()); - while (cachedAccounts.size() > maxCachedAccounts) { - final Map.Entry eldest = cachedAccounts.entrySet().iterator().next(); - cachedAccounts.remove(eldest.getKey()); - evicted.add(eldest.getValue()); - } - } - if (!evicted.isEmpty()) { - logger.warn("The Entra ID token cache reached {} accounts. Evicting {} that went longest without acquiring a token.", - maxCachedAccounts, evicted.size()); - evicted.forEach(this::removeAccount); - } - } - - /** - * Sets the maximum number of accounts kept in the shared application's token cache. - * @param maxCachedAccounts The maximum number of accounts. - */ - public void setMaxCachedAccounts(final int maxCachedAccounts) { - this.maxCachedAccounts = maxCachedAccounts; - } - - /** - * Drops an account's tokens from the shared client application's cache. - * - * @param account The account to evict. - */ - protected void removeAccount(final IAccount account) { - // Unconditional, and ahead of the join below: an eviction has already taken the entry out, - // and a logout has to take it out whether or not MSAL4J manages to prune its own cache. - synchronized (cachedAccounts) { - cachedAccounts.remove(account.homeAccountId()); - } - try { - getClientApplication().removeAccount(account).join(); - if (logger.isDebugEnabled()) { - logger.debug("Removed an account from the token cache."); - } - } catch (final Exception e) { - // Logging out must not fail because the cache could not be pruned. - logger.warn("Failed to remove an account from the Entra ID token cache.", e); - } - } - - /** - * Kept only so an out-of-tree {@code fess_sso+entraidAuthenticator.xml} that still sets this - * property keeps loading. The value is ignored. - * - *

The v1.0 endpoint is not supported. msal4j hardcodes {@code oauth2/v2.0/token} as the - * token endpoint of an AAD authority and offers no v1 alternative, so an authorization code - * minted at {@code /oauth2/authorize} could never be redeemed -- the v1.0 branch this used to - * select produced a login that always failed. - * - * @param useV2Endpoint Ignored. - * @deprecated The v1.0 endpoint is unsupported; the authorization request is always v2.0. - */ - @Deprecated - public void setUseV2Endpoint(final boolean useV2Endpoint) { - if (!useV2Endpoint) { - logger.warn("useV2Endpoint=false is ignored. The Entra ID v1.0 endpoint is not supported:" - + " msal4j only redeems authorization codes at the v2.0 token endpoint."); - } - } -} diff --git a/src/main/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticator.java b/src/main/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticator.java deleted file mode 100644 index ad3dad266a..0000000000 --- a/src/main/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticator.java +++ /dev/null @@ -1,458 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.oic; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.core.misc.DynamicProperties; -import org.codelibs.fess.app.web.base.login.ActionResponseCredential; -import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver; -import org.codelibs.fess.app.web.base.login.OpenIdConnectCredential; -import org.codelibs.fess.crawler.Constants; -import org.codelibs.fess.sso.SsoAuthenticator; -import org.codelibs.fess.util.ComponentUtil; -import org.dbflute.optional.OptionalEntity; -import org.lastaflute.web.login.credential.LoginCredential; -import org.lastaflute.web.response.HtmlResponse; -import org.lastaflute.web.util.LaRequestUtil; - -import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl; -import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest; -import com.google.api.client.auth.oauth2.TokenResponse; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonParser; -import com.google.api.client.json.JsonToken; -import com.google.api.client.json.gson.GsonFactory; -import com.google.common.io.BaseEncoding; -import com.google.common.io.BaseEncoding.DecodingException; - -import jakarta.annotation.PostConstruct; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - -/** - * OpenID Connect authenticator for SSO integration. - */ -public class OpenIdConnectAuthenticator implements SsoAuthenticator { - - /** - * Default constructor. - */ - public OpenIdConnectAuthenticator() { - // Default constructor - } - - private static final Logger logger = LogManager.getLogger(OpenIdConnectAuthenticator.class); - - private static final BaseEncoding BASE64_DECODER = BaseEncoding.base64().withSeparator("\n", 64); - - private static final BaseEncoding BASE64URL_DECODER = BaseEncoding.base64Url().withSeparator("\n", 64); - - /** Configuration key for OpenID Connect authorization server URL. */ - protected static final String OIC_AUTH_SERVER_URL = "oic.auth.server.url"; - - /** Configuration key for OpenID Connect client ID. */ - protected static final String OIC_CLIENT_ID = "oic.client.id"; - - /** Configuration key for OpenID Connect scope. */ - protected static final String OIC_SCOPE = "oic.scope"; - - /** Configuration key for OpenID Connect redirect URL. */ - protected static final String OIC_REDIRECT_URL = "oic.redirect.url"; - - /** Configuration key for OpenID Connect token server URL. */ - protected static final String OIC_TOKEN_SERVER_URL = "oic.token.server.url"; - - /** Configuration key for OpenID Connect client secret. */ - protected static final String OIC_CLIENT_SECRET = "oic.client.secret"; - - /** Session key for OpenID Connect state parameter. */ - protected static final String OIC_STATE = "OIC_STATE"; - - /** Configuration key for OpenID Connect base URL. */ - protected static final String OIC_BASE_URL = "oic.base.url"; - - /** HTTP transport for OpenID Connect requests. */ - protected final HttpTransport httpTransport = new NetHttpTransport(); - - /** JSON factory for OpenID Connect response parsing. */ - protected final JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); - - /** - * Initializes the OpenID Connect authenticator. - */ - @PostConstruct - public void init() { - if (logger.isDebugEnabled()) { - logger.debug("Initializing {}", this.getClass().getSimpleName()); - } - ComponentUtil.getSsoManager().register(this); - } - - @Override - public LoginCredential getLoginCredential() { - return LaRequestUtil.getOptionalRequest().map(request -> { - if (logger.isDebugEnabled()) { - logger.debug("Logging in with OpenID Connect Authenticator"); - } - final HttpSession session = request.getSession(false); - if (session != null) { - final String sesState = (String) session.getAttribute(OIC_STATE); - if (StringUtil.isNotBlank(sesState)) { - session.removeAttribute(OIC_STATE); - final String code = request.getParameter("code"); - final String reqState = request.getParameter("state"); - if (logger.isDebugEnabled()) { - logger.debug("code: {}, state(request): {}, state(session): {}", code, reqState, sesState); - } - if (sesState.equals(reqState)) { - final String error = request.getParameter("error"); - if (StringUtil.isNotBlank(error)) { - // The provider answered this login with an error response (RFC 6749 section - // 4.1.2.1). Falling through to a new authorization request would either bounce - // between the two servers until the browser gives up, when the provider keeps - // refusing, or hand back a code and log the user in anyway, when it refuses only - // because the user declined the consent. Report it instead, so the caller shows - // the login error. - logger.warn("The OpenID provider rejected the authorization request: error={}, error_description={}", error, - request.getParameter("error_description")); - return null; - } - if (StringUtil.isNotBlank(code)) { - return processCallback(request, code); - } - } - } - } - - return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(getAuthUrl(request))); - }).orElse(null); - } - - /** - * Gets the authorization URL for OpenID Connect. - * - * @param request the HTTP servlet request - * @return the authorization URL - */ - protected String getAuthUrl(final HttpServletRequest request) { - // UUID.randomUUID is backed by SecureRandom and varies in 122 bits. The state is the only - // thing standing between a login and a forged callback (RFC 6749 section 10.12), and - // org.codelibs.core.net.UuidUtil, which this used to call, is - // hex(localIP) + hex(identityHashCode(RANDOM)) + hex((int) (currentTimeMillis() >> 32)) + - // hex(SecureRandom.nextInt()): its first 16 hex characters are constant for the life of - // the JVM and under 32 bits actually vary per call. The value is only ever compared with - // equals() against the copy held in the session, so its length and format are free. - final String state = UUID.randomUUID().toString(); - request.getSession().setAttribute(OIC_STATE, state); - return new AuthorizationCodeRequestUrl(getOicAuthServerUrl(), getOicClientId())// - .setScopes(Arrays.asList(getOicScope()))// - .setResponseTypes(Arrays.asList("code"))// - .setRedirectUri(getOicRedirectUrl())// - .setState(state)// - .build(); - } - - /** - * Decodes a Base64 string to bytes. - * - * @param base64String the Base64 string to decode - * @return the decoded bytes, or null if input is null - */ - protected byte[] decodeBase64(final String base64String) { - if (base64String == null) { - return null; - } - try { - return BASE64_DECODER.decode(base64String); - } catch (final IllegalArgumentException e) { - if (e.getCause() instanceof DecodingException) { - return BASE64URL_DECODER.decode(base64String.trim()); - } - throw e; - } - } - - /** - * Processes the callback from OpenID Connect provider. - * - * @param request the HTTP servlet request - * @param code the authorization code - * @return the login credential - */ - protected LoginCredential processCallback(final HttpServletRequest request, final String code) { - try { - final TokenResponse tr = getTokenUrl(code); - - // Everything below reads a document the provider controls. Each malformed shape has to end - // as a returned null, which the caller turns into the SSO login error, and not as a thrown - // RuntimeException, which leaves the browser on a system error page. - if (!(tr.get("id_token") instanceof final String idToken) || StringUtil.isBlank(idToken)) { - logger.warn("The token response carries no id_token, so there is no user to log in."); - return null; - } - final String[] jwt = idToken.split("\\."); - if (jwt.length != 3) { - logger.warn("The id_token is not a JWT: it has {} dot-separated segments instead of 3.", jwt.length); - return null; - } - final String jwtHeader = new String(decodeBase64(jwt[0]), Constants.UTF_8_CHARSET); - final String jwtClaim = new String(decodeBase64(jwt[1]), Constants.UTF_8_CHARSET); - final String jwtSignature = new String(decodeBase64(jwt[2]), Constants.UTF_8_CHARSET); - - if (logger.isDebugEnabled()) { - logger.debug("jwtHeader={}", jwtHeader); - logger.debug("jwtClaim={}", jwtClaim); - // The signature is raw bytes, not text. Writing the decoded string put control and - // invalid-UTF-8 bytes straight into fess.log, which makes the file itself count as - // binary: grep and the rest of the usual log tooling then skip it silently. - logger.debug("jwtSignature: {} encoded characters, not validated", jwt[2].length()); - } - - // SECURITY WARNING: JWT signature validation is not implemented. - // This is a critical security vulnerability. The ID token should be validated - // to ensure it was issued by the expected OpenID Connect provider and has not been tampered with. - // TODO: Implement JWT signature validation using the provider's public key - - final Map attributes = new HashMap<>(); - attributes.put("accesstoken", tr.getAccessToken()); - attributes.put("refreshtoken", tr.getRefreshToken() == null ? "null" : tr.getRefreshToken()); - attributes.put("tokentype", tr.getTokenType()); - attributes.put("expire", tr.getExpiresInSeconds()); - attributes.put("jwtheader", jwtHeader); - attributes.put("jwtclaim", jwtClaim); - attributes.put("jwtsignature", jwtSignature); - - if (logger.isDebugEnabled()) { - // Not the whole attribute map: it holds the access token and the refresh token, which - // are bearer credentials for the provider. The documentation tells an administrator to - // turn this logger up to debug when a login misbehaves, so whatever it prints ends up - // in a file that is read, copied into issue reports and shipped to log collectors. - logger.debug("tokenType={}, expiresInSeconds={}, refreshToken={}", tr.getTokenType(), tr.getExpiresInSeconds(), - tr.getRefreshToken() == null ? "absent" : "present"); - } - parseJwtClaim(jwtClaim, attributes); - - final OpenIdConnectCredential credential = new OpenIdConnectCredential(attributes); - if (StringUtil.isBlank(credential.getUserId())) { - // The user id is the email claim. Without it the credential resolves to a user with a - // null name, which the login itself accepts and every later request then fails on, so - // the session has to be refused here rather than created and left unusable. - logger.warn("The ID token has no email claim, which is the user id. Check that {} requests it.", OIC_SCOPE); - return null; - } - return credential; - } catch (final IOException | IllegalArgumentException e) { - // This endpoint is anonymous, so anyone can drive a failing callback. A message keeps a - // misbehaving provider diagnosable; the stack trace stays behind the debug level so an - // unauthenticated client cannot fill the log with them. - logger.warn("Failed to process the OpenID Connect callback: {}", e.getMessage()); - if (logger.isDebugEnabled()) { - logger.debug("Failed to process callback request.", e); - } - } - return null; - } - - /** - * Parses the JWT claim and extracts attributes. - * - * @param jwtClaim the JWT claim string - * @param attributes the attributes map to populate - * @throws IOException if an I/O error occurs - */ - protected void parseJwtClaim(final String jwtClaim, final Map attributes) throws IOException { - try (final JsonParser jsonParser = jsonFactory.createJsonParser(jwtClaim)) { - attributes.putAll(parseObject(jsonParser)); - } - } - - /** - * Parses primitive values from JSON parser. - * - * @param jsonParser the JSON parser - * @return the parsed primitive value - * @throws IOException if an I/O error occurs - */ - protected Object parsePrimitive(final JsonParser jsonParser) throws IOException { - final JsonToken token = jsonParser.getCurrentToken(); - return switch (token) { - case VALUE_STRING -> jsonParser.getText(); - case VALUE_NUMBER_INT -> jsonParser.getLongValue(); - case VALUE_NUMBER_FLOAT -> jsonParser.getDoubleValue(); - case VALUE_TRUE -> true; - case VALUE_FALSE -> false; - case VALUE_NULL -> null; - default -> null; // Or throw an exception if unexpected token - }; - } - - /** - * Parses array values from JSON parser. - * - * @param jsonParser the JSON parser - * @return the parsed array as a list - * @throws IOException if an I/O error occurs - */ - protected Object parseArray(final JsonParser jsonParser) throws IOException { - final List list = new ArrayList<>(); - while (jsonParser.nextToken() != JsonToken.END_ARRAY) { - if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) { - list.add(parseObject(jsonParser)); - } else if (jsonParser.getCurrentToken() == JsonToken.START_ARRAY) { - list.add(parseArray(jsonParser)); // Nested array - } else { - list.add(parsePrimitive(jsonParser)); - } - } - - return list; - } - - /** - * Parses object values from JSON parser. - * - * @param jsonParser the JSON parser - * @return the parsed object as a map - * @throws IOException if an I/O error occurs - */ - protected Map parseObject(final JsonParser jsonParser) throws IOException { - final Map nestedMap = new HashMap<>(); - while (jsonParser.nextToken() != JsonToken.END_OBJECT) { - final String fieldName = jsonParser.getCurrentName(); - if (fieldName != null) { - jsonParser.nextToken(); // Move to the value of the current field - - if (jsonParser.getCurrentToken() == JsonToken.START_ARRAY) { - nestedMap.put(fieldName, parseArray(jsonParser)); - } else if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) { - nestedMap.put(fieldName, parseObject(jsonParser)); - } else { - nestedMap.put(fieldName, parsePrimitive(jsonParser)); - } - } - } - return nestedMap; - } - - /** - * Gets the token response from the OpenID Connect provider. - * - * @param code the authorization code - * @return the token response - * @throws IOException if an I/O error occurs - */ - protected TokenResponse getTokenUrl(final String code) throws IOException { - return new AuthorizationCodeTokenRequest(httpTransport, jsonFactory, new GenericUrl(getOicTokenServerUrl()), code)// - .setGrantType("authorization_code")// - .setRedirectUri(getOicRedirectUrl())// - .set("client_id", getOicClientId())// - .set("client_secret", getOicClientSecret())// - .execute(); - } - - /** - * Gets the OpenID Connect client secret. - * - * @return the client secret - */ - protected String getOicClientSecret() { - return ComponentUtil.getSystemProperties().getProperty(OIC_CLIENT_SECRET, StringUtil.EMPTY); - } - - /** - * Gets the OpenID Connect token server URL. - * - * @return the token server URL - */ - protected String getOicTokenServerUrl() { - return ComponentUtil.getSystemProperties().getProperty(OIC_TOKEN_SERVER_URL, "https://accounts.google.com/o/oauth2/token"); - } - - /** - * Gets the OpenID Connect redirect URL. - * - * @return the redirect URL - */ - protected String getOicRedirectUrl() { - final String redirectUrl = ComponentUtil.getSystemProperties().getProperty(OIC_REDIRECT_URL); - return redirectUrl != null ? redirectUrl : buildDefaultRedirectUrl(); - } - - /** - * Builds a default redirect URL for OpenID Connect based on the environment. - * Uses the configured base URL or defaults to http://localhost:8080 for compatibility - * with common OIDC provider configurations. - * - * @return the default redirect URL - */ - protected String buildDefaultRedirectUrl() { - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - String baseUrl = systemProperties.getProperty(OIC_BASE_URL); - if (StringUtil.isBlank(baseUrl)) { - baseUrl = "http://localhost:8080"; - } - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.substring(0, baseUrl.length() - 1); - } - return baseUrl + "/sso/"; - } - - /** - * Gets the OpenID Connect scope. - * - * @return the scope - */ - protected String getOicScope() { - return ComponentUtil.getSystemProperties().getProperty(OIC_SCOPE, StringUtil.EMPTY); - } - - /** - * Gets the OpenID Connect client ID. - * - * @return the client ID - */ - protected String getOicClientId() { - return ComponentUtil.getSystemProperties().getProperty(OIC_CLIENT_ID, StringUtil.EMPTY); - } - - /** - * Gets the OpenID Connect authorization server URL. - * - * @return the authorization server URL - */ - protected String getOicAuthServerUrl() { - return ComponentUtil.getSystemProperties().getProperty(OIC_AUTH_SERVER_URL, "https://accounts.google.com/o/oauth2/auth"); - } - - @Override - public void resolveCredential(final LoginCredentialResolver resolver) { - resolver.resolve(OpenIdConnectCredential.class, credential -> OptionalEntity.of(credential.getUser())); - } - -} diff --git a/src/main/java/org/codelibs/fess/sso/saml/SamlAuthenticator.java b/src/main/java/org/codelibs/fess/sso/saml/SamlAuthenticator.java deleted file mode 100644 index 9784abf113..0000000000 --- a/src/main/java/org/codelibs/fess/sso/saml/SamlAuthenticator.java +++ /dev/null @@ -1,1605 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.saml; - -import java.io.OutputStreamWriter; -import java.io.Writer; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.core.misc.DynamicProperties; -import org.codelibs.fess.app.web.base.login.ActionResponseCredential; -import org.codelibs.fess.app.web.base.login.FessLoginAssist; -import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver; -import org.codelibs.fess.app.web.base.login.SamlCredential; -import org.codelibs.fess.app.web.base.login.SamlCredential.SamlUser; -import org.codelibs.fess.crawler.Constants; -import org.codelibs.fess.exception.SsoLoginException; -import org.codelibs.fess.exception.SsoMessageException; -import org.codelibs.fess.exception.SsoProcessException; -import org.codelibs.fess.exception.SsoStateException; -import org.codelibs.fess.mylasta.action.FessUserBean; -import org.codelibs.fess.sso.SsoAuthenticator; -import org.codelibs.fess.sso.SsoResponseType; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.saml2.Auth; -import org.codelibs.saml2.core.authn.AuthnRequestParams; -import org.codelibs.saml2.core.exception.SAMLException; -import org.codelibs.saml2.core.exception.ValidationException; -import org.codelibs.saml2.core.logout.LogoutRequest; -import org.codelibs.saml2.core.logout.LogoutRequestParams; -import org.codelibs.saml2.core.replay.InMemoryReplayCache; -import org.codelibs.saml2.core.replay.ReplayCache; -import org.codelibs.saml2.core.settings.Saml2Settings; -import org.codelibs.saml2.core.settings.SettingsBuilder; -import org.codelibs.saml2.core.util.Util; -import org.dbflute.optional.OptionalEntity; -import org.dbflute.optional.OptionalThing; -import org.lastaflute.core.message.UserMessages; -import org.lastaflute.web.login.credential.LoginCredential; -import org.lastaflute.web.response.ActionResponse; -import org.lastaflute.web.response.HtmlResponse; -import org.lastaflute.web.response.StreamResponse; -import org.lastaflute.web.util.LaRequestUtil; -import org.lastaflute.web.util.LaResponseUtil; -import org.w3c.dom.Document; - -import jakarta.annotation.PostConstruct; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; - -/** - * Authenticator for SAML 2.0. - * - *

This authenticator enables Single Sign-On (SSO) using SAML 2.0 protocol - * with Identity Providers such as Okta, Azure AD, OneLogin, etc.

- * - *

Required Configuration

- *

Add the following properties to {@code system.properties}:

- *
- * # Enable SAML SSO
- * sso.type=saml
- *
- * # Identity Provider settings (obtain from your IdP)
- * saml.idp.entityid=http://www.okta.com/xxxxx
- * saml.idp.single_sign_on_service.url=https://your-domain.okta.com/app/xxxxx/sso/saml
- * saml.idp.x509cert=MIIDqjCCApKgAwIBAgIGAYMwfYAwMA0G...
- * 
- * - *

Service Provider URL Configuration

- *

By default, the SP URLs use {@code http://localhost:8080} as the base URL. - * For production or when the IdP is configured with a different URL, you should - * set one of the following:

- * - *

Option 1: Set base URL (recommended for simplicity)

- *
- * # All SP URLs will be derived from this base URL
- * saml.sp.base.url=https://your-fess-server.example.com
- * 
- * - *

Option 2: Set individual SP URLs

- *
- * # SP Entity ID (Audience URI in IdP)
- * saml.sp.entityid=https://your-fess-server.example.com/sso/metadata
- *
- * # Assertion Consumer Service URL
- * saml.sp.assertion_consumer_service.url=https://your-fess-server.example.com/sso/
- *
- * # Single Logout Service URL
- * saml.sp.single_logout_service.url=https://your-fess-server.example.com/sso/logout
- * 
- * - *

Complete Configuration Example (Okta)

- *
- * sso.type=saml
- *
- * # IdP settings from Okta SAML setup instructions
- * saml.idp.entityid=http://www.okta.com/your-app-id
- * saml.idp.single_sign_on_service.url=https://your-domain.okta.com/app/your-app/your-app-id/sso/saml
- * saml.idp.x509cert=MIIDqjCCApKg... (your IdP certificate)
- *
- * # SP base URL (must match Audience URI configured in Okta)
- * saml.sp.base.url=https://your-fess-server.example.com
- * 
- * - *

Optional Configuration

- *
- * # User attribute mapping
- * saml.attribute.group.name=groups
- * saml.attribute.role.name=roles
- *
- * # Default groups/roles for authenticated users
- * saml.default.groups=user
- * saml.default.roles=user
- * 
- *

An assertion that carries the same attribute name on more than one element is refused, and - * the login fails before any of the mapping above is reached. An IdP that emits one - * {@code } element per value produces such an assertion -- Keycloak does unless the - * {@code single} option of its role and group mappers is enabled -- and the repeats are accepted - * and merged only with:

- *
- * saml.security.allow_duplicated_attribute_name=true
- * 
- * - *

Security Settings (Production)

- *

For production environments, consider enabling these security features:

- *
- * saml.security.authnrequest_signed=true
- * saml.security.want_messages_signed=true
- * saml.security.want_assertions_signed=true
- * 
- *

{@code saml.security.want_messages_signed} matters in particular once - * {@code saml.idp.single_logout_service.url} is configured. It defaults to {@code false} because - * not every IdP signs its LogoutRequest, but while it is {@code false} the single logout service - * accepts a LogoutRequest that nobody authenticated, and reaching it needs no knowledge of the - * deployment: {@code Issuer} is optional in the SAML protocol schema and java-saml compares it - * only when the element is present, so a LogoutRequest that omits it is never matched against the - * IdP entity ID. A session logged in through SAML is protected by - * {@link #isLogoutRequestForAnotherUser}, which refuses to end a session the LogoutRequest does - * not name; what remains exposed is a session whose NameID the sender already knows, and any - * session that did not come from SAML at all, because there is then no NameID to compare. The - * impact is a forced logout, not account takeover. This is reported once as - * {@code unsigned_logoutrequest_accepted} in the insecure-settings warning.

- * - *

Session Cookie Settings (Required)

- *

The IdP returns the assertion as a cross-site POST to the assertion consumer service. - * A {@code SameSite=Lax} cookie is not sent on such a request, so the shipped default in - * {@code tomcat_config.properties} has to be changed for SAML:

- *
- * tomcat.sameSiteCookies = none
- * 
- *

{@code none} is only accepted by browsers on a {@code Secure} cookie, so Fess must be - * served over HTTPS.

- * - * @see Fess Documentation - */ -public class SamlAuthenticator implements SsoAuthenticator { - - /** - * Constructor. - */ - public SamlAuthenticator() { - } - - private static final Logger logger = LogManager.getLogger(SamlAuthenticator.class); - - /** - * The prefix for SAML properties. - */ - protected static final String SAML_PREFIX = "saml."; - - /** - * The session key holding the IDs of the AuthnRequests sent to the IdP that have not been - * answered yet. Each ID is compared with the InResponseTo of the SAML response. - * - *

The value is a {@code Map} of AuthnRequest ID to the time it was created, - * not a single ID: a browser with several tabs open sends one AuthnRequest per tab, and a - * single slot means the second overwrites the first, after which the first assertion to come - * back consumes the slot, fails the InResponseTo comparison and takes both logins down with - * it. {@code FessSearchAction} redirects every unauthenticated page hit to {@code /sso/}, so - * that happens as soon as a session expires with more than one tab open.

- * - *

The key is unchanged from the release that stored a bare {@link String} here, so that an - * existing session keeps working across an upgrade; see {@link #getRequestIdMap(HttpSession)} - * for how such a value is migrated.

- */ - protected static final String SAML_STATE = "SAML_STATE"; - - /** - * The property key for the SAML SP base URL. - */ - protected static final String SAML_SP_BASE_URL = "saml.sp.base.url"; - - /** Upper bound on the length of a sender-supplied NameID embedded in a log message. */ - protected static final int MAX_LOGGED_NAME_ID_LENGTH = 64; - - /** - * Upper bound on the length of a rejection reason embedded in a log message. Longer than a - * NameID because the reason is a sentence that quotes what it objected to -- an entity ID, a - * destination, an audience -- and truncating it to a NameID's length would cut off the part - * that identifies the problem. - */ - protected static final int MAX_LOGGED_FAILURE_REASON_LENGTH = 512; - - /** - * Characters that must not be copied verbatim into a log message. {@code \p{Cntrl}} alone is - * ASCII-only, so the Unicode break characters a log viewer still renders as a new line are - * listed explicitly. - */ - private static final Pattern LOG_UNSAFE_PATTERN = Pattern.compile("[\\p{Cntrl}\\u0085\\u2028\\u2029]"); - - /** - * The property key for how long, in seconds, an unanswered AuthnRequest ID stays usable. Only - * a positive value is honoured; see {@link #getRequestIdTtl()}. - */ - protected static final String SAML_REQUEST_ID_TTL = "saml.request.id.ttl"; - - /** - * The time-to-live applied to an unanswered AuthnRequest ID when - * {@link #SAML_REQUEST_ID_TTL} is absent, blank, not a number, or not positive. One hour, the - * same default {@code EntraIdAuthenticator} uses for an OpenID Connect state, and comfortably - * longer than any interactive login at an IdP. - */ - protected static final String DEFAULT_REQUEST_ID_TTL = "3600"; - - /** - * The number of unanswered AuthnRequest IDs kept per session when {@code fess_sso++.xml} - * leaves the cap alone, and the value {@link #setMaxRequestIds} falls back to when the - * configured one is not positive. Ten abandoned logins in one session is already well past - * anything a person does by hand. - */ - protected static final int DEFAULT_MAX_REQUEST_IDS = 10; - - /** - * Maximum number of unanswered AuthnRequest IDs kept per session. Every visit to - * {@code /sso/} without a SAML response stores one, and {@code /sso/} is anonymous and - * answers GET, so without a cap a page that embeds it as a sub-resource would grow the - * session attribute without bound. It also bounds the number of candidates - * {@link #processSamlResponse} tries. - * - *

Always positive when it is set through {@link #setMaxRequestIds}, which is the only path - * a configured value takes; a subclass that assigns the field directly is on its own.

- */ - protected int maxRequestIds = DEFAULT_MAX_REQUEST_IDS; - - private Map defaultSettings; - - /** - * Cache of processed assertion IDs, used to reject replayed assertions. - * - *

Note: the cache is held in memory by this instance and is not shared in a - * multi-instance deployment. That is also why SAML SSO needs sticky sessions: the - * assertion has to reach the instance whose session holds the matching AuthnRequest ID. - * It is that ID check, not this cache, which rejects an assertion replayed to another - * instance; the cache catches a repeated POST within a single session.

- */ - private final ReplayCache replayCache = new InMemoryReplayCache(); - - /** - * The security warnings reported the last time they were logged, so that a settings - * rebuild does not repeat them until the settings change. - */ - private final AtomicReference> loggedSecurityWarnings = new AtomicReference<>(); - - /** - * The most recently built settings, paired with the parameters they were built from. - * - * @param params the parameters the settings were built from; values are always {@link String}, - * never a {@link java.net.URL}, whose {@code equals} would resolve DNS - * @param settings the settings built from {@code params} - */ - private record CachedSettings(Map params, Saml2Settings settings) { - } - - /** - * The settings built for the parameters currently in effect. - * - *

Building is not cheap and is not silent: it re-parses the IdP certificate and the SP - * private key, and {@code SettingsBuilder.build()} logs one line per security warning, so a - * per-request build defeats the deduplication in {@link #logSecurityWarnings(Saml2Settings)}.

- * - *

An {@link AtomicReference} rather than a plain field because it is required for safe - * publication: every field of {@link Saml2Settings} is non-final and non-volatile, so another - * thread could otherwise observe a partially initialized instance.

- */ - private final AtomicReference cachedSettings = new AtomicReference<>(); - - /** - * Initializes the SamlAuthenticator. - */ - @PostConstruct - public void init() { - if (logger.isDebugEnabled()) { - logger.debug("Initializing {}", this.getClass().getSimpleName()); - } - ComponentUtil.getSsoManager().register(this); - defaultSettings = createDefaultSettings(); - } - - /** - * Creates the settings that are applied before the {@code saml.} system properties. - * - *

NOTE: Many security settings are set to false for compatibility. - * For production use, it is STRONGLY RECOMMENDED to enable security features: - * {@code saml.security.authnrequest_signed}, {@code saml.security.want_messages_signed} - * and {@code saml.security.want_assertions_signed}.

- * - *

The SP endpoint URLs are not included here because they are derived from - * {@code saml.sp.base.url}, which can be changed at runtime. They are built by - * {@link #buildSettingsParams()}.

- * - * @return The default settings. - */ - protected Map createDefaultSettings() { - final Map settings = new HashMap<>(); - settings.put("onelogin.saml2.strict", "true"); - settings.put("onelogin.saml2.debug", "false"); - settings.put("onelogin.saml2.sp.assertion_consumer_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"); - settings.put("onelogin.saml2.sp.single_logout_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"); - settings.put("onelogin.saml2.sp.nameidformat", "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"); - settings.put("onelogin.saml2.idp.single_sign_on_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"); - settings.put("onelogin.saml2.idp.single_logout_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"); - settings.put("onelogin.saml2.security.nameid_encrypted", "false"); - settings.put("onelogin.saml2.security.authnrequest_signed", "false"); - settings.put("onelogin.saml2.security.logoutrequest_signed", "false"); - settings.put("onelogin.saml2.security.logoutresponse_signed", "false"); - settings.put("onelogin.saml2.security.want_messages_signed", "false"); - settings.put("onelogin.saml2.security.want_assertions_signed", "false"); - settings.put("onelogin.saml2.security.want_assertions_encrypted", "false"); - settings.put("onelogin.saml2.security.want_nameid_encrypted", "false"); - settings.put("onelogin.saml2.security.requested_authncontext", "urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); - settings.put("onelogin.saml2.security.requested_authncontextcomparison", "exact"); - settings.put("onelogin.saml2.security.want_xml_validation", "true"); - settings.put("onelogin.saml2.security.signature_algorithm", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"); - settings.put("onelogin.saml2.organization.name", "CodeLibs"); - settings.put("onelogin.saml2.organization.displayname", "Fess"); - settings.put("onelogin.saml2.organization.url", "https://fess.codelibs.org/"); - settings.put("onelogin.saml2.contacts.technical.given_name", "Technical Guy"); - settings.put("onelogin.saml2.contacts.technical.email_address", "technical@example.com"); - settings.put("onelogin.saml2.contacts.support.given_name", "Support Guy"); - settings.put("onelogin.saml2.contacts.support.email_address", "support@example.com"); - return settings; - } - - /** - * Builds a default URL for SAML endpoints. - * Uses the configured base URL or defaults to http://localhost:8080 for compatibility - * with common SAML IdP configurations. - * - * @param path the path to append to the base URL - * @return the complete URL - */ - protected String buildDefaultUrl(final String path) { - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - String baseUrl = systemProperties.getProperty(SAML_SP_BASE_URL); - if (StringUtil.isBlank(baseUrl)) { - baseUrl = "http://localhost:8080"; - } - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.substring(0, baseUrl.length() - 1); - } - return baseUrl + path; - } - - /** - * Builds the parameters the SAML settings are built from. - * - *

The {@code saml.} system properties are applied without filtering out blank values: - * {@code SettingsBuilder} already treats a blank value as absent, so a present-but-blank - * property falls through to the library default. That is what lets - * {@code saml.security.requested_authncontext=} suppress the {@code RequestedAuthnContext} - * element, which is the documented way of not constraining the authentication method.

- * - *

The three SP endpoint URLs are the exception: they are computed from - * {@code saml.sp.base.url} rather than defaulted by the library, so they are filled in only - * when the matching property is absent or blank.

- * - *

Every value in the returned map is a {@link String}. A {@link java.net.URL} must never be - * put in it, because the map is compared with {@code equals} and {@code URL.equals} resolves - * DNS.

- * - * @return The parameters for {@link SettingsBuilder}. - */ - protected Map buildSettingsParams() { - final Map params = new HashMap<>(defaultSettings); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - systemProperties.entrySet().stream().forEach(e -> { - final String key = e.getKey().toString(); - if (!key.startsWith(SAML_PREFIX)) { - return; - } - params.put("onelogin.saml2." + key.substring(SAML_PREFIX.length()), e.getValue()); - }); - // resolved here, not in createDefaultSettings(), because saml.sp.base.url can be changed - // at runtime from the admin UI - putComputedSpUrl(params, "onelogin.saml2.sp.entityid", "/sso/metadata"); - putComputedSpUrl(params, "onelogin.saml2.sp.assertion_consumer_service.url", "/sso/"); - putComputedSpUrl(params, "onelogin.saml2.sp.single_logout_service.url", "/sso/logout"); - return params; - } - - /** - * Derives an SP endpoint URL from {@code saml.sp.base.url} unless the corresponding property - * already carries a usable value. - * - *

A present-but-blank property must not wipe out the derived URL: the library would treat - * it as absent and leave the SP endpoint unset, which fails {@code checkSPSettings()}.

- * - * @param params The parameters being built. - * @param key The {@code onelogin.saml2.} key to fill in. - * @param path The path to append to the base URL. - */ - protected void putComputedSpUrl(final Map params, final String key, final String path) { - if (params.get(key) instanceof final String s && StringUtil.isNotBlank(s)) { - return; - } - params.put(key, buildDefaultUrl(path)); - } - - /** - * Gets the SAML settings. - * - *

The settings are cached and rebuilt only when the parameters change, because building - * them re-parses the IdP certificate and the SP private key and logs the security warnings - * again.

- * - * @return The SAML settings. - */ - protected Saml2Settings getSettings() { - final Map params = buildSettingsParams(); - final CachedSettings cached = cachedSettings.get(); - if (cached != null && cached.params().equals(params)) { - return cached.settings(); - } - // deliberately get()/set() rather than updateAndGet() or a synchronized block: the - // mapping function of updateAndGet() may be retried, and building has logging side - // effects that must not be repeated. Two threads building at once is harmless; both - // produce equivalent settings and the last write wins. - final Saml2Settings settings = new SettingsBuilder().fromValues(params).build(); - settings.setReplayCache(replayCache); - logSecurityWarnings(settings); - cachedSettings.set(new CachedSettings(params, settings)); - return settings; - } - - /** - * Logs the security warnings reported for the given settings. - * The warnings are logged again only when they change, so that a settings rebuild does not - * repeat them. - * - * @param settings The SAML settings. - */ - protected void logSecurityWarnings(final Saml2Settings settings) { - final List warnings = new ArrayList<>(settings.getSecurityWarnings()); - if (settings.getIdpSingleLogoutServiceResponseUrl() != null && !settings.getWantMessagesSigned()) { - // /sso/logout accepts a LogoutRequest that is not signed, so anyone who can lure a - // logged-in user to a crafted URL can reach it; not even the IdP entity ID is needed, - // since Issuer is optional in the protocol schema and java-saml compares it only when - // it is present. isLogoutRequestForAnotherUser() keeps such a request from ending a - // SAML session it does not name, but a session whose NameID the sender already knows, - // and a session that did not come from SAML and therefore has no NameID to compare, - // are still ended by it. - warnings.add("unsigned_logoutrequest_accepted"); - } - final Set allowedKeyTransport = settings.getAllowedKeyTransportAlgorithms(); - if (settings.getSPkey() != null && (allowedKeyTransport == null || allowedKeyTransport.isEmpty())) { - // With an SP private key configured, /sso/ decrypts an EncryptedAssertion before it - // validates anything: SamlResponse decrypts in its constructor, and processResponse() - // only calls isValid() afterwards. The endpoint is anonymous, and an AuthnRequest ID - // to quote back is one GET /sso/ away, so an unauthenticated caller can drive the SP - // private key through RSA decryption with a ciphertext of their choosing. - // java-saml accepts every key transport algorithm when the allow-list is unset, which - // includes the RSA-1_5 padding that Bleichenbacher-style attacks target. Fess does not - // set it, so say so rather than changing a default that would break an IdP relying on - // the old algorithm. - warnings.add("key_transport_algorithms_not_restricted"); - } - if (!warnings.equals(loggedSecurityWarnings.getAndSet(warnings)) && !warnings.isEmpty()) { - logger.warn("Insecure SAML settings: {}. See the SAML SSO documentation for the recommended values.", - String.join(", ", warnings)); - } - } - - @Override - public LoginCredential getLoginCredential() { - return LaRequestUtil.getOptionalRequest().map(request -> { - if (logger.isDebugEnabled()) { - logger.debug("Logging in with SAML Authenticator"); - } - - final HttpServletResponse response = LaResponseUtil.getResponse(); - - if (containsSamlResponse(request)) { - final HttpSession session = request.getSession(false); - // counted here rather than inside removeExpiredRequestIds so that the pruning - // itself keeps the signature it ships with; see logUnmatchedSamlResponseAfterExpiry - int expiredCount = 0; - if (session != null) { - final Map requestIdMap = getRequestIdMap(session); - final int pendingCount = requestIdMap.size(); - removeExpiredRequestIds(requestIdMap); - if (!requestIdMap.isEmpty()) { - try { - return processSamlResponse(request, response, requestIdMap); - } catch (final SAMLException e) { - // The assertion consumer service is anonymous, and a SAMLResponse that - // cannot be decoded or parsed is refused before the pending - // AuthnRequest ID is consumed (see processSamlResponse), so the same - // request can be repeated for the whole TTL. A stack trace per attempt - // would let an unauthenticated client fill the log; SsoAction already - // makes this split for SsoStateException. - if (isDuplicatedAttributeName(e)) { - logDuplicatedAttributeName(); - } else { - logger.warn("Authentication failed: {}", describeSamlFailure(e)); - } - if (logger.isDebugEnabled()) { - logger.debug("Authentication failed.", e); - } - return null; - } catch (final Exception e) { - logger.warn("Authentication failed.", e); - return null; - } - } - // pruning is the only thing this thread removed, so an emptied map that was not - // empty a moment ago says how many logins ran out of time. A concurrent request - // of the same session can consume or evict an entry too, which would make this - // an over-count; it only shapes a log line, and the response was unanswerable - // by this thread either way. - expiredCount = pendingCount; - } - // No session at all, or one that holds nothing still answerable: the assertion - // cannot be tied to an AuthnRequest this server sent, so it is refused rather - // than answered with another one. - if (expiredCount > 0) { - logUnmatchedSamlResponseAfterExpiry(expiredCount); - } else if (hasExpiredSession(request)) { - logUnmatchedSamlResponseAfterSessionExpiry(); - } else { - logUnmatchedSamlResponse(0); - } - return null; - } - - try { - final Auth auth = new Auth(getSettings(), request, response); - final AuthnRequestParams authnRequestParams = new AuthnRequestParams(false, false, true); - final String loginUrl = auth.login(null, authnRequestParams, true); - storeRequestIdInSession(request.getSession(), auth.getLastRequestId()); - return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(loginUrl)); - } catch (final Exception e) { - throw new SsoLoginException("Invalid SAML redirect URL.", e); - } - - }).orElse(null); - } - - /** - * Renders a SAML failure as the single line that stands in for its stack trace at WARN. - * - *

The cause chain is rendered, not just {@code getMessage()}, because the exception a - * malformed response produces is often the one that says the least: java-saml wraps a parse - * failure as {@code XMLParsingException("Failed to load XML data.", cause)}, whose own - * message names neither what failed nor where. Everything actionable lives further down the - * chain, so dropping it would trade a noisy log for a useless one.

- * - *

Split out as its own method so that an extension can reshape or shorten this line - * without having to re-implement the WARN/DEBUG split around it. The full stack trace stays - * available at DEBUG either way.

- * - * @param throwable The failure to describe. - * @return The chain rendered as {@code Type: message} entries joined by {@code " <- "}, - * stopping at the first cause already seen so that a cyclic chain terminates. - */ - protected String describeSamlFailure(final Throwable throwable) { - final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); - final StringBuilder buf = new StringBuilder(); - for (Throwable current = throwable; current != null && seen.add(current); current = current.getCause()) { - if (buf.length() > 0) { - buf.append(" <- "); - } - buf.append(current.getClass().getSimpleName()); - if (StringUtil.isNotBlank(current.getMessage())) { - buf.append(": ").append(current.getMessage()); - } - } - return buf.toString(); - } - - /** - * Answers a SAML response with the pending AuthnRequest it names. - * - *

{@code Auth.processResponse} takes exactly one AuthnRequest ID and compares it with the - * InResponseTo of the response, and java-saml 3.1.1 exposes no supported way of reading that - * InResponseTo beforehand: {@code SamlResponse} has no getter for it, and the only other - * route would be to base64-decode and parse the {@code SAMLResponse} parameter here, which - * would add an XML parsing surface -- and therefore an XXE surface -- to an endpoint that is - * anonymous by design. So the candidates are tried one at a time instead.

- * - *

That is cheap and, more importantly, safe, because of where the comparison sits in - * {@code SamlResponse.isValid}: it runs before signature validation and before the assertion - * ID is registered with the replay cache, so a candidate the response does not name fails - * fast and leaves no trace behind. A candidate that fails for any other reason has already - * passed the comparison, so there is nothing left to try and the loop stops there; that is - * also what keeps the reported error the real one rather than the InResponseTo mismatch of - * whichever candidate happened to be tried last.

- * - *

Only a response that authenticates consumes its AuthnRequest ID. Consuming it on failure - * as well would look tidier, but telling "named this ID and was then rejected" apart from - * "was rejected before the ID was even looked at" means enumerating java-saml's internal - * ordering of checks, and getting that wrong hands anyone who can reach the assertion - * consumer service -- a cross-site POST, since SAML requires {@code SameSite=none} -- a way - * to burn a pending login per request. The TTL and {@link #maxRequestIds} bound the map - * instead.

- * - *

With {@code saml.strict=false} the library skips the InResponseTo comparison entirely, - * so the first candidate either authenticates or fails for a real reason and no further - * candidate is tried. That matches the library's contract: without strict mode there is no - * InResponseTo binding to match against.

- * - * @param request The HTTP request carrying the SAML response. - * @param response The HTTP response. - * @param requestIdMap The pending AuthnRequest IDs of the session, already pruned of expired - * entries. The matching entry is removed from it on success. - * @return The login credential, or null when the response is not accepted. - */ - protected LoginCredential processSamlResponse(final HttpServletRequest request, final HttpServletResponse response, - final Map requestIdMap) { - Auth lastAuth = null; - for (final String requestId : getCandidateRequestIds(requestIdMap)) { - final Auth auth = createAuth(request, response); - auth.processResponse(requestId); - if (auth.isAuthenticated()) { - requestIdMap.remove(requestId); - return createLoginCredential(request, response, auth); - } - lastAuth = auth; - if (!isInResponseToMismatch(auth)) { - break; - } - } - if (lastAuth == null || isInResponseToMismatch(lastAuth)) { - logUnmatchedSamlResponse(requestIdMap.size()); - return null; - } - final String errors = String.join(", ", lastAuth.getErrors()); - // The reason is reported whatever saml.debug is set to. getErrors() answers a category -- - // "invalid_response" for a bad signature, an expired assertion, a foreign audience and a - // replay alike -- so on its own it tells an administrator only that the login failed. The - // detail used to reach the log anyway because java-saml logged it at warn as well; it now - // leaves that to whoever calls it, and getLastErrorReason() carries it either way. - final String reason = lastAuth.getLastErrorReason(); - if (StringUtil.isNotBlank(reason)) { - // The reason quotes the message it objected to -- an issuer, a destination, an - // audience -- and this endpoint is anonymous, so the quoted part is a sender's own - // input and is bounded and stripped of control characters like any other. - logger.warn("Authentication Failure: {} - Reason: {}", errors, sanitizeForLog(reason, MAX_LOGGED_FAILURE_REASON_LENGTH)); - } else { - logger.warn("Authentication Failure: {}", errors); - } - return null; - } - - /** - * Creates the {@link Auth} that processes one candidate AuthnRequest ID. - * - *

A fresh instance per candidate is required, not an optimisation left undone: - * {@code Auth} accumulates its errors, its authenticated flag and its last validation - * exception across calls, so reusing one would report the first candidate's InResponseTo - * mismatch alongside whatever the matching candidate produced.

- * - * @param request The HTTP request. - * @param response The HTTP response. - * @return A new SAML authentication object. - */ - protected Auth createAuth(final HttpServletRequest request, final HttpServletResponse response) { - return new Auth(getSettings(), request, response); - } - - /** - * Returns the pending AuthnRequest IDs to try, most recently created first. - * - *

Most recent first because the overwhelmingly common case is a single login, whose ID is - * the newest one; every earlier entry is a tab or a visit the user abandoned. The list is - * capped at {@link #maxRequestIds} even though the map is already bounded on write, so that - * lowering the cap at runtime takes effect on the very next response rather than only once - * the surplus entries have been evicted.

- * - * @param requestIdMap The pending AuthnRequest IDs of the session. - * @return The AuthnRequest IDs to try, in the order they are tried. - */ - protected List getCandidateRequestIds(final Map requestIdMap) { - return requestIdMap.entrySet() - .stream() - .sorted(Comparator.comparingLong((final Map.Entry e) -> e.getValue()).reversed()) - .limit(maxRequestIds) - .map(Map.Entry::getKey) - .collect(Collectors.toList()); - } - - /** - * Returns whether the given attempt failed only because the response names a different - * AuthnRequest, which is the one failure that says nothing about the response itself and is - * therefore worth retrying with the next candidate. - * - * @param auth The attempt that did not authenticate. - * @return true if the response's InResponseTo did not match the candidate that was tried. - */ - protected boolean isInResponseToMismatch(final Auth auth) { - return auth.getLastValidationException() instanceof final ValidationException e - && e.getErrorCode() == ValidationException.WRONG_INRESPONSETO; - } - - /** - * Logs the one line reported when a SAML response cannot be tied to an AuthnRequest this - * server sent. - * - *

It is a warning and not a redirect back to the IdP on purpose: answering an unmatched - * assertion with a fresh AuthnRequest sends the browser to an IdP that is already - * authenticated, which posts the same kind of unmatched assertion straight back, and the - * loop only ends when the browser gives up.

- * - *

The SameSite guidance below is what this case usually is, but only because the two ways - * a login can instead have run out of time are reported elsewhere: an ID pruned by - * {@link #SAML_REQUEST_ID_TTL} by {@link #logUnmatchedSamlResponseAfterExpiry(int)}, and a - * session the container has already discarded by - * {@link #logUnmatchedSamlResponseAfterSessionExpiry()}. Without that split every expired - * login would read as a cookie misconfiguration, since neither leaves anything pending and - * both therefore reach this line with a pending count of zero as well.

- * - * @param pendingCount How many pending AuthnRequest IDs the session held, so that a log can - * tell a missing session cookie apart from a response that simply matched none of - * several live logins. - */ - protected void logUnmatchedSamlResponse(final int pendingCount) { - logger.warn(""" - Received a SAML response with no matching AuthnRequest ID in the session ({} pending).\ - The assertion consumer service is a cross-site POST, which does not carry a SameSite=Lax cookie;\ - see tomcat.sameSiteCookies in tomcat_config.properties.\ - An IdP-initiated (unsolicited) response is rejected for the same reason.""", pendingCount); - } - - /** - * Logs the one line reported when a SAML response arrives after every AuthnRequest ID its - * session held had passed {@link #SAML_REQUEST_ID_TTL}. - * - *

Kept apart from {@link #logUnmatchedSamlResponse(int)} because that line names - * {@code tomcat.sameSiteCookies} as the cause, and here the session cookie demonstrably did - * arrive: the session was found and it did hold pending IDs until this request pruned them. - * Sending an operator whose cookie settings are already correct off to change them is what - * the split avoids; the only line that told the two apart was the {@code debug} one in - * {@link #removeExpiredRequestIds(Map)}, which is below the level a shipped Fess logs at.

- * - *

This is an ordinary event rather than a misconfiguration -- a user who starts a login and - * finishes it at the IdP later than the TTL allows reaches it, and simply starting the login - * again succeeds -- so it is worth telling apart from the cases that need an administrator.

- * - *

An extension that overrides {@link #logUnmatchedSamlResponse(int)} to reshape or suppress - * that warning wants to override this one as well: until this method existed, the expiry case - * was reported by that one with a pending count of zero.

- * - * @param expiredCount How many pending AuthnRequest IDs had expired, that is, how many logins - * the session still had in flight before the TTL removed them. - */ - protected void logUnmatchedSamlResponseAfterExpiry(final int expiredCount) { - logger.warn(""" - Received a SAML response after all {} pending AuthnRequest ID(s) of the session had expired.\ - The session cookie did reach this server, so this is not the SameSite case:\ - the login took longer to finish at the IdP than {} allows, and starting it again resolves it.""", expiredCount, - SAML_REQUEST_ID_TTL); - } - - /** - * Logs the one line reported when a SAML response arrives with a session id the container no - * longer recognises, so the session that held the AuthnRequest ID is gone rather than merely - * empty. - * - *

This, not {@link #logUnmatchedSamlResponseAfterExpiry(int)}, is what a login left too - * long at the IdP actually reaches on a stock Fess, because the session runs out first: the - * AuthnRequest ID is kept for {@link #DEFAULT_REQUEST_ID_TTL} seconds, an hour, while - * {@code WEB-INF/web.xml} sets no {@code session-timeout} and nothing calls - * {@code setMaxInactiveInterval}, which leaves the servlet container's own default of thirty - * minutes. The session is therefore discarded, IDs and all, some half an hour before any of - * those IDs can expire, and the response comes back to a {@code getSession(false)} that - * returns null. Without this line that lands on {@link #logUnmatchedSamlResponse(int)} and is - * reported as a {@code SameSite} cookie problem -- the very misdiagnosis the expiry line was - * added to prevent, in the one case that occurs in practice.

- * - *

The two are told apart by {@link #hasExpiredSession(HttpServletRequest)}: a browser that - * is not sending the session cookie sends no session id at all, so a request that does carry - * one demonstrably kept the cookie and lost only the session behind it.

- * - *

Like the TTL case this is an ordinary event rather than a misconfiguration, and starting - * the login again resolves it. It names the container's session timeout instead of - * {@link #SAML_REQUEST_ID_TTL} on purpose, since raising the TTL cannot extend a session that - * is already the shorter of the two.

- * - *

An extension that overrides {@link #logUnmatchedSamlResponse(int)} to reshape or suppress - * that warning wants to override this one as well: until this method existed, this case was - * reported by that one with a pending count of zero.

- */ - protected void logUnmatchedSamlResponseAfterSessionExpiry() { - logger.warn(""" - Received a SAML response after the session it belongs to had expired.\ - The browser did return its session cookie, so this is not the SameSite case:\ - the session, and with it the AuthnRequest ID it held, was discarded by the\ - container's session timeout rather than by {}, so raising that value does not help.\ - Starting the login again resolves it.""", SAML_REQUEST_ID_TTL); - } - - /** - * Returns whether the given failure is the library refusing an assertion that carries the same - * attribute name more than once. - * - *

Matched on {@link ValidationException#DUPLICATED_ATTRIBUTE_NAME_FOUND} rather than on the - * message, which is the library's to change.

- * - * @param e The failure that ended the login. - * @return True if the assertion repeated an attribute name. - */ - protected boolean isDuplicatedAttributeName(final SAMLException e) { - return e instanceof final ValidationException ve && ve.getErrorCode() == ValidationException.DUPLICATED_ATTRIBUTE_NAME_FOUND; - } - - /** - * Logs the one line reported when the IdP put the same attribute name on more than one - * element of the assertion. - * - *

Told apart from the generic failure line because the generic one -- "Found an Attribute - * element with duplicated Name" -- names a fact about the XML and leaves an administrator with - * nothing to change. The setting that accepts the repeats exists, but nothing in Fess mentions - * it, so without this line the deployment is a dead end.

- * - *

It is worth its own line because of how it is reached rather than how rare it is: this - * refusal happens in {@code Auth#processResponse} after {@code SamlResponse#isValid} has - * already returned true, while the attributes are being read, so the signature, the - * InResponseTo comparison and the replay check all passed. An administrator who is told only - * that an assertion was refused reasonably suspects the certificate or the clock, none of - * which is involved.

- * - *

Keycloak is named because it is listed as a supported IdP and produces this on a stock - * configuration: its role and group mappers emit one {@code } element per value - * unless their {@code single} option is enabled, and every Keycloak account carries several - * default realm roles, so every login of every user fails. The failure does not depend on - * Fess mapping those attributes -- a deployment that sets no - * {@code saml.attribute.role.name} at all fails identically, because the refusal is in the - * library, before Fess is given anything to map.

- * - *

The pending AuthnRequest ID is not consumed by this failure, so a login retried after the - * IdP or Fess is reconfigured still has its ID to match against.

- */ - protected void logDuplicatedAttributeName() { - logger.warn(""" - The IdP repeated an attribute name in the SAML assertion, which is refused, so the login failed\ - while the attributes were being read; the assertion itself passed validation and no group or\ - role was mapped. An IdP that emits one element per value produces this: Keycloak\ - does unless the "single" option of its role and group mappers is enabled, and every Keycloak\ - account carries several default roles. Either aggregate each attribute into a single element\ - at the IdP, or set {}=true in system.properties to accept the repeats and merge their values.""", - SAML_PREFIX + "security.allow_duplicated_attribute_name"); - } - - /** - * Returns whether the request carries a session id that the container no longer recognises. - * - *

This is what tells an expired session apart from a browser that is not sending the - * cookie: a request with no session id at all cannot have lost one. Named after the - * {@code EntraIdAuthenticator} method that answers the same question, so that the two - * authenticators stay recognisable to each other.

- * - *

Unlike there, the answer only chooses a log line here. An unmatched SAML response is - * refused either way, because restarting the login by redirecting to an IdP that is already - * authenticated would only bring the same unmatched assertion straight back.

- * - * @param request The HTTP servlet request. - * @return True if a session id was sent and it is no longer valid. - */ - protected boolean hasExpiredSession(final HttpServletRequest request) { - return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid(); - } - - /** - * Records the ID of an AuthnRequest that has just been sent to the IdP, pruning the entries - * that can no longer be answered first. - * - * @param session The HTTP session. - * @param requestId The ID of the AuthnRequest sent to the IdP. - */ - protected void storeRequestIdInSession(final HttpSession session, final String requestId) { - final Map requestIdMap = getRequestIdMap(session); - removeExpiredRequestIds(requestIdMap); - removeOldestRequestIds(requestIdMap, maxRequestIds - 1); - if (logger.isDebugEnabled()) { - logger.debug("Storing AuthnRequest ID in session: {}", requestId); - } - requestIdMap.put(requestId, ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); - } - - /** - * Returns the per-session map of unanswered AuthnRequest IDs, creating it if needed. - * - *

The map is concurrent, and the create is synchronized on the session, because the tabs - * that make several logins possible in the first place can also start them at the same - * moment: a plain {@code HashMap} created twice loses the ID one of them has to match - * later.

- * - *

Anything else found under the key is replaced rather than cast. A session that predates - * this change holds a bare {@link String} there, and blindly casting it would end the login - * with a {@link ClassCastException} rather than a message; that single ID is carried over so - * a login already in flight across the upgrade can still complete.

- * - * @param session The HTTP session. - * @return The AuthnRequest ID map held by the session, keyed by ID and valued with the time - * the ID was created. - */ - protected Map getRequestIdMap(final HttpSession session) { - synchronized (session) { - final Object stored = session.getAttribute(SAML_STATE); - if (stored instanceof ConcurrentHashMap) { - return (Map) stored; - } - final Map concurrentMap = new ConcurrentHashMap<>(); - if (stored instanceof final String requestId && StringUtil.isNotBlank(requestId)) { - concurrentMap.put(requestId, ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); - } - session.setAttribute(SAML_STATE, concurrentMap); - return concurrentMap; - } - } - - /** - * Drops the AuthnRequest IDs that are older than the configured TTL. - * - * @param requestIdMap The AuthnRequest ID map to prune. - */ - protected void removeExpiredRequestIds(final Map requestIdMap) { - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final long requestIdTtl = getRequestIdTtl(); - requestIdMap.entrySet() - .stream() - .filter(e -> (now - e.getValue()) / 1000L > requestIdTtl) - .map(Map.Entry::getKey) - .collect(Collectors.toList()) - .forEach(requestId -> { - if (logger.isDebugEnabled()) { - logger.debug("Removing expired AuthnRequest ID: {}", requestId); - } - requestIdMap.remove(requestId); - }); - } - - /** - * Drops the least recently created AuthnRequest IDs until at most {@code limit} remain. An - * AuthnRequest that is never answered does not expire before the TTL, so this is what bounds - * the map for a client that keeps starting logins. - * - * @param requestIdMap The AuthnRequest ID map to prune. - * @param limit The number of AuthnRequest IDs to keep. - */ - protected void removeOldestRequestIds(final Map requestIdMap, final int limit) { - if (requestIdMap.size() <= limit) { - return; - } - requestIdMap.entrySet() - .stream() - .sorted(Comparator.comparingLong(Map.Entry::getValue)) - .limit((long) requestIdMap.size() - limit) - .map(Map.Entry::getKey) - .collect(Collectors.toList()) - .forEach(requestId -> { - if (logger.isDebugEnabled()) { - logger.debug("Removing surplus AuthnRequest ID: {}", requestId); - } - requestIdMap.remove(requestId); - }); - } - - /** - * Sets the maximum number of unanswered AuthnRequest IDs kept per session. - * - *

A value that is not positive is reported and replaced by - * {@link #DEFAULT_MAX_REQUEST_IDS} rather than taken literally, for the same reason - * {@link #getRequestIdTtl()} refuses one. Nothing fails as it is applied, but - * {@link #getCandidateRequestIds} hands it to {@code limit()}: {@code 0} leaves - * {@link #processSamlResponse} no candidate to try, so every SAML login in the deployment - * fails and is reported by {@link #logUnmatchedSamlResponse} as the cookie problem it is not, - * and a negative value makes {@code limit()} throw an {@link IllegalArgumentException} that - * reaches the log only as "Authentication failed.". {@code 0} is not a far-fetched value to - * write either -- it reads as "no limit" -- which is why it falls back rather than being - * taken at its word. The warning names the property and the value. Refusing it here is - * enough because this setter is the only path a configured value takes; a subclass that - * assigns the field directly bypasses the check.

- * - * @param maxRequestIds The maximum number of AuthnRequest IDs. Only a positive value is - * honoured. - */ - public void setMaxRequestIds(final int maxRequestIds) { - if (maxRequestIds <= 0) { - logger.warn("maxRequestIds must be a positive number: {}. Using {}.", maxRequestIds, DEFAULT_MAX_REQUEST_IDS); - this.maxRequestIds = DEFAULT_MAX_REQUEST_IDS; - return; - } - this.maxRequestIds = maxRequestIds; - } - - /** - * Gets how long an unanswered AuthnRequest ID stays usable. - * - *

A value that is not a number is reported once per read rather than thrown, because the - * alternative is a login that dies with a {@code NumberFormatException} nobody can act - * on.

- * - *

A value that is not positive is treated the same way, and for the same reason. It parses, - * so nothing would fail here, but {@link #removeExpiredRequestIds} compares - * {@code (now - created) / 1000} against it: {@code 0} drops an AuthnRequest ID one second - * after it was issued and a negative value drops it at once, so no IdP round trip could ever - * complete and every SAML login in the deployment would fail. {@code 0} is not a far-fetched - * value to write either -- it reads as "no expiry", and elsewhere in Fess that is what it - * means -- which is why it falls back rather than being taken literally. The warning names the - * property and the value, and is worded differently from the one above so that a log says - * which of the two mistakes was made.

- * - * @return The TTL in seconds, always positive. {@link #removeExpiredRequestIds} compares it - * against an elapsed time that has already been divided by 1000. - */ - protected long getRequestIdTtl() { - final long defaultTtl = Long.parseLong(DEFAULT_REQUEST_ID_TTL); - final String value = ComponentUtil.getFessConfig().getSystemProperty(SAML_REQUEST_ID_TTL); - if (StringUtil.isBlank(value)) { - return defaultTtl; - } - final long requestIdTtl; - try { - requestIdTtl = Long.parseLong(value.trim()); - } catch (final NumberFormatException e) { - logger.warn("Invalid {}: {}. Using {} seconds.", SAML_REQUEST_ID_TTL, value, DEFAULT_REQUEST_ID_TTL); - return defaultTtl; - } - if (requestIdTtl <= 0) { - logger.warn("{} must be a positive number of seconds: {}. Using {} seconds.", SAML_REQUEST_ID_TTL, value, - DEFAULT_REQUEST_ID_TTL); - return defaultTtl; - } - return requestIdTtl; - } - - /** - * Returns whether the request carries a SAML response, which is what the IdP posts to the - * assertion consumer service. - * - *

The session is deliberately not consulted here: it is the session cookie that goes - * missing when the browser refuses to send it on the cross-site POST, and a callback that is - * mistaken for a fresh visit is redirected back to the IdP forever.

- * - *

Only solicited responses are accepted. Fess binds every response to the ID of the - * AuthnRequest it sent, so an unsolicited (IdP-initiated) response has nothing to match - * against and is rejected rather than answered with a fresh AuthnRequest.

- * - * @param request The HTTP request. - * @return true if the request carries a SAML response. - */ - protected boolean containsSamlResponse(final HttpServletRequest request) { - return StringUtil.isNotBlank(request.getParameter("SAMLResponse")); - } - - /** - * Creates a login credential. - * @param request The HTTP request. - * @param response The HTTP response. - * @param auth The SAML authentication. - * @return The login credential. - */ - protected LoginCredential createLoginCredential(final HttpServletRequest request, final HttpServletResponse response, final Auth auth) { - final SamlCredential samlCredential = new SamlCredential(auth); - if (logger.isDebugEnabled()) { - logger.debug("SamlCredential: {}", samlCredential); - } - return samlCredential; - } - - @Override - public void resolveCredential(final LoginCredentialResolver resolver) { - resolver.resolve(SamlCredential.class, credential -> OptionalEntity.of(credential.getUser())); - } - - @Override - public String logout(final FessUserBean user) { - if (user.getFessUser() instanceof final SamlUser samlUser) { - return LaRequestUtil.getOptionalRequest().map(request -> { - if (logger.isDebugEnabled()) { - logger.debug("Logging out with SAML Authenticator"); - } - final HttpServletResponse response = LaResponseUtil.getResponse(); - try { - final Saml2Settings settings = getSettings(); - if (settings.getIdpSingleLogoutServiceUrl() == null) { - if (logger.isDebugEnabled()) { - logger.debug("IdP single logout service URL is not configured, skipping SLO for user: {}", samlUser); - } - return null; - } - final Auth auth = new Auth(settings, request, response); - final LogoutRequestParams logoutRequestParams = new LogoutRequestParams(samlUser.getSessionIndex(), samlUser.getName(), - samlUser.getNameIdFormat(), samlUser.getNameidNameQualifier(), samlUser.getNameidSPNameQualifier()); - return auth.logout(null, logoutRequestParams, true); - } catch (final Exception e) { - logger.warn("Failed to logout from IdP: name={}", samlUser.getName(), e); - } - return null; - }).orElse(null); - } - return null; - } - - @Override - public ActionResponse getResponse(final SsoResponseType responseType) { - return switch (responseType) { - case METADATA -> getMetadataResponse(); - case LOGOUT -> getLogoutResponse(); - default -> null; - }; - } - - /** - * Builds the exception used to report a failed SSO request to the user. - * - * @param action The action being performed, used as the log message. - * @param msg The reason, shown to the user. - * @param cause The underlying cause. An {@code SsoStateException} marks the failure as caused - * by the client, which {@code SsoAction} logs without a stack trace. - * @return The exception to throw. - */ - protected SsoMessageException processFailure(final String action, final String msg, final Exception cause) { - return new SsoMessageException(messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, msg), - action, cause); - } - - /** - * Builds the exception used to report a failed SSO request that has no underlying exception. - * - * @param action The action being performed, used as the log message. - * @param msg The reason, shown to the user. - * @return The exception to throw. - */ - protected SsoMessageException processFailure(final String action, final String msg) { - return processFailure(action, msg, new SsoProcessException(msg)); - } - - /** - * Gets the metadata response. - * - *

The SP metadata is what the IdP is registered from, so it has to be obtainable before - * any {@code saml.idp.*} property exists. Only the SP settings are therefore validated; - * constructing an {@link Auth} here would validate the IdP settings in its constructor and - * fail while they are still empty.

- * - * @return The metadata response. - */ - /** - * Warns when metadata signing was asked for but cannot happen. - * - *

{@code Saml2Settings#getSPMetadata()} signs with the SP key and certificate and swallows - * any failure at debug level, returning the unsigned document. So with - * {@code saml.security.sign_metadata=true} and no key material, {@code /sso/metadata} answers - * 200 with unsigned metadata and nothing says the request was dropped. An operator who turned - * signing on has no way to tell it is not happening.

- * - *

This is reported rather than refused: the metadata is still correct, only unsigned, and - * an IdP that does not check the signature keeps working. Refusing would turn a working - * deployment into a broken one on upgrade.

- * - * @param settings The SAML settings. - */ - protected void warnIfMetadataCannotBeSigned(final Saml2Settings settings) { - if (settings.getSignMetadata() && (settings.getSPkey() == null || settings.getSPcert() == null)) { - logger.warn( - "saml.security.sign_metadata is enabled but the SP metadata cannot be signed, so it is published unsigned. " - + "Signing needs both saml.sp.privatekey and saml.sp.x509cert; missing: {}.", - settings.getSPkey() == null - ? (settings.getSPcert() == null ? "saml.sp.privatekey, saml.sp.x509cert" : "saml.sp.privatekey") - : "saml.sp.x509cert"); - } - } - - protected ActionResponse getMetadataResponse() { - return LaRequestUtil.getOptionalRequest().map(request -> { - if (logger.isDebugEnabled()) { - logger.debug("Accessing metadata with SAML Authenticator"); - } - try { - final Saml2Settings settings = getSettings(); - // checkSettings() with spValidationOnly is by definition checkSPSettings(), and - // mutating the shared settings instance to say so would leak into every other - // caller - final List settingsErrors = settings.checkSPSettings(); - if (!settingsErrors.isEmpty()) { - final String msg = String.join(", ", settingsErrors); - throw processFailure("Failed to process metadata.", msg); - } - warnIfMetadataCannotBeSigned(settings); - final String metadata = settings.getSPMetadata(); - final List errors = Saml2Settings.validateMetadata(metadata); - if (!errors.isEmpty()) { - final String msg = String.join(", ", errors); - throw processFailure("Failed to process metadata.", msg); - } - return new StreamResponse("metadata.xml").contentType("application/samlmetadata+xml").stream(out -> { - try (final Writer writer = new OutputStreamWriter(out.stream(), Constants.UTF_8_CHARSET)) { - writer.write(metadata); - } - }); - } catch (final SsoMessageException e) { - throw e; - } catch (final Exception e) { - throw processFailure("Failed to process metadata.", e.getMessage(), e); - } - }).orElseThrow(() -> processFailure("Failed to process metadata.", "Invalid state.")); - } - - /** - * Returns whether the request carries a SAML logout message, which is what the IdP sends to - * the single logout service. - * - *

{@code /sso/logout} is reachable without authentication, so it also receives plain visits - * that carry no SAML message at all. Those are not logout callbacks and must be rejected - * before {@code Auth.processSLO} sees them, because it answers them with an exception whose - * text describes the supported bindings rather than anything the visitor can act on.

- * - * @param request The HTTP request. - * @return true if the request carries a SAML logout request or response. - */ - protected boolean containsSamlLogoutMessage(final HttpServletRequest request) { - return StringUtil.isNotBlank(request.getParameter("SAMLRequest")) || StringUtil.isNotBlank(request.getParameter("SAMLResponse")); - } - - /** - * Gets the logout response. - * @return The logout response. - */ - protected ActionResponse getLogoutResponse() { - return LaRequestUtil.getOptionalRequest(). map(request -> { - if (logger.isDebugEnabled()) { - logger.debug("Logging out with SAML Authenticator"); - } - final HttpServletResponse response = LaResponseUtil.getResponse(); - try { - if (!containsSamlLogoutMessage(request)) { - // an anonymous request that is not a logout callback: rejected, not a fault, - // so it carries an SsoStateException and is logged without a stack trace. - // Checked before the configuration guard below, because otherwise a - // deployment that leaves single logout unconfigured would answer the very - // same anonymous visit with a stack trace per request. - final String msg = "This endpoint expects a SAML logout message from the IdP."; - throw processFailure("Failed to log out.", msg, new SsoStateException(msg)); - } - final Saml2Settings settings = getSettings(); - if (settings.getIdpSingleLogoutServiceResponseUrl() == null) { - final String msg = "IdP single logout service URL is not configured."; - throw processFailure("Failed to log out.", msg); - } - final Auth auth = new Auth(settings, request, response); - // A LogoutRequest that names somebody else must not take this session with it, but - // it is still answered with an ordinary LogoutResponse: an error would tell an - // unauthenticated sender whether it guessed a live session, and would leave a - // confused-but-legitimate IdP with no way of finishing its own logout. - // - // A LogoutResponse never ends a session at all; see isLogoutResponse. - final boolean keepLocalSession = isLogoutResponse(request) || isLogoutRequestForAnotherUser(request, settings); - warnIfLogoutResponseReachedALiveLogin(request); - // stay=true keeps java-saml from committing the servlet response itself - final String redirectUrl = auth.processSLO(keepLocalSession, null, true); - final List errors = auth.getErrors(); - if (!errors.isEmpty()) { - // java-saml refused the message the sender supplied -- a replayed ID, a bad - // signature, a missing NameID, XML that will not parse. The endpoint is - // anonymous and, because SAML requires SameSite=none, reachable cross-site, so - // this is a rejected request rather than a fault: an SsoStateException gets it - // logged without a stack trace, the way getLoginCredential already treats a - // callback it did not start. - final String msg = String.join(", ", errors); - throw processFailure("Failed to log out.", msg, new SsoStateException(msg)); - } - if (StringUtil.isNotBlank(redirectUrl)) { - // an IdP-initiated LogoutRequest: send our LogoutResponse back to the IdP - return HtmlResponse.fromRedirectPathAsIs(redirectUrl); - } - throw new SsoMessageException(messages -> messages.addSuccessSsoLogout(UserMessages.GLOBAL_PROPERTY_KEY), "Logged out"); - } catch (final SsoMessageException e) { - throw e; - } catch (final Exception e) { - throw processFailure("Failed to log out.", e.getMessage(), e); - } - }).orElseThrow(() -> processFailure("Failed to log out.", "Invalid state.")); - } - - /** - * Returns whether the incoming logout message is a LogoutResponse, in which case it must not - * end the local session. - * - *

A LogoutResponse is the answer to a LogoutRequest this SP sent, and {@code LogoutAction} - * has already ended the local login by the time one can arrive: it asks the SSO manager for - * the redirect URL and then calls {@code logout()}, which invalidates the session. Whatever - * session the answer lands on is therefore a fresh one, so invalidating it ends nothing that - * was still running -- and that is the only thing a legitimate LogoutResponse gave up here.

- * - *

What it costs is the rest of the deployment. {@code /sso/logout} is anonymous and, - * because SAML requires {@code SameSite=none}, reachable cross-site with the victim's session - * cookie attached. {@code Auth#processSLO} is given no request ID to bind the answer to -- the - * SP has none to give, having just discarded the session that would have held it -- so - * java-saml skips the {@code InResponseTo} comparison, and with the shipped - * {@code saml.security.want_messages_signed=false} every remaining check is conditional on an - * attribute the sender may simply omit: {@code Issuer}, {@code Destination}, and the - * {@code InResponseTo} attribute itself. A LogoutResponse carrying nothing but a Success - * status was therefore enough to invalidate any session it was pointed at -- a SAML login, a - * local one, or a login still in flight, whose pending AuthnRequest ID went with the session - * and left the user unable to log in while the page kept firing. No signature, no guess, and - * nothing in the log.

- * - *

This is the same exposure {@link #isLogoutRequestForAnotherUser} closes for the other - * kind of message, where the NameID is what costs the sender a guess. A LogoutResponse carries - * no NameID, so there is nothing to compare -- and nothing that needs comparing, because there - * is nothing left for it to end.

- * - * @param request The HTTP request carrying the SAML logout message. - * @return true if the message is a LogoutResponse. - */ - protected boolean isLogoutResponse(final HttpServletRequest request) { - return StringUtil.isBlank(request.getParameter("SAMLRequest")) && StringUtil.isNotBlank(request.getParameter("SAMLResponse")); - } - - /** - * Reports a LogoutResponse that reached a session which is still logged in. - * - *

The legitimate answer arrives after {@code LogoutAction} has ended the login, so this - * says nothing on the path a logout actually takes. It says something on the one it does not: - * a LogoutResponse aimed at a live login answers a logout that was never started, which is - * either a stale replay out of a browser's history or a request forged to end somebody's - * session. Reporting it is what makes the attempt visible; the session is kept either way.

- * - *

One bounded line and no stack trace, for the reason the rest of this endpoint gives: it - * is anonymous, so a rejected message must not let an unauthenticated client fill the log. - * Nothing the sender supplied is echoed, because nothing in the message was validated.

- * - * @param request The HTTP request carrying the SAML logout message. - */ - protected void warnIfLogoutResponseReachedALiveLogin(final HttpServletRequest request) { - if (!isLogoutResponse(request) || !isLoggedIn()) { - return; - } - logger.warn("A SAML LogoutResponse reached a session that is still logged in, so it is answered without ending it." - + " A LogoutResponse answers a LogoutRequest this server sent, and this server had not sent one:" - + " the message is a replay or was forged to end the session. An IdP that starts a logout sends a LogoutRequest instead."); - } - - /** - * Returns whether this session is logged in, answering false when that cannot be determined. - * - * @return true if a user is logged in. - */ - protected boolean isLoggedIn() { - try { - return getSavedUserBean().isPresent(); - } catch (final Exception e) { - // this endpoint has to keep working for a request that reaches it outside a login - // scope, so being unable to look at the session means "cannot tell", not "fail" - if (logger.isDebugEnabled()) { - logger.debug("Failed to read the session user.", e); - } - return false; - } - } - - /** - * Returns whether an IdP-initiated LogoutRequest names somebody other than the user this - * session is logged in as, in which case the session must survive it. - * - *

{@code /sso/logout} is anonymous and, because SAML requires {@code SameSite=none}, is - * reachable cross-site with the victim's session cookie attached. With the shipped default - * {@code saml.security.want_messages_signed=false} java-saml accepts a LogoutRequest that - * carries no signature, and every other check it makes is conditional on an attribute the - * sender simply omits -- {@code NotOnOrAfter}, {@code Destination}, and even {@code Issuer}, - * which the protocol schema declares optional and whose absence therefore skips the entity ID - * comparison as well. The NameID is the one element java-saml insists on, so it is the one - * thing left worth checking, and comparing it with the session costs an attacker the guess.

- * - *

A LogoutResponse the IdP is answering ({@code SAMLResponse}) is left alone here: it - * carries no NameID to compare, and constructing a {@link LogoutRequest} from such a request - * would silently build a fresh outgoing message rather than parse anything. - * {@link #isLogoutResponse(HttpServletRequest)} keeps the session for that kind of message - * instead, on the ground that there is nothing left for it to end.

- * - *

Anything that is not a clear mismatch keeps the previous behaviour of ending the session: - * no user logged in, a user who did not come from SAML, a message whose NameID cannot be read - * at all. Those are properties of this deployment or of a message java-saml is about to reject - * anyway, not values a sender chooses.

- * - *

A NameID that is read but empty is not one of them. It is the sender's own input, so - * treating it as "cannot tell" would hand back exactly the bypass this method exists to close: - * java-saml requires the {@code } element to be present but does not require it to - * carry anything, so {@code } parses, names nobody, and would end any session it - * reached. It is therefore compared like any other value and, naming nobody, never matches. No - * IdP is lost by this: one that ends a session says whose.

- * - * @param request The HTTP request carrying the SAML logout message. - * @param settings The SAML settings, used to parse the LogoutRequest the way java-saml - * itself parses it a moment later. - * @return true if the session must be kept because the LogoutRequest names another user. - */ - protected boolean isLogoutRequestForAnotherUser(final HttpServletRequest request, final Saml2Settings settings) { - if (StringUtil.isBlank(request.getParameter("SAMLRequest"))) { - return false; - } - final String sessionNameId = getSessionSamlNameId(); - if (StringUtil.isBlank(sessionNameId)) { - return false; - } - final String logoutRequestNameId = getLogoutRequestNameId(request, settings); - if (logoutRequestNameId == null) { - // the message could not be read at all; java-saml is about to fail on the same bytes - return false; - } - if (isSameNameId(sessionNameId, logoutRequestNameId)) { - return false; - } - logger.warn("The LogoutRequest names '{}' but this session is logged in as '{}', so it is answered without ending the session." - + " If a legitimate single logout stopped working, compare the NameID the IdP puts in its assertion with the one it puts" - + " in its LogoutRequest.", sanitizeForLog(logoutRequestNameId), sanitizeForLog(sessionNameId)); - return true; - } - - /** - * Bounds a NameID and strips its control characters so that it can be embedded in a log - * message. - * - *

The NameID of the LogoutRequest reaches this log before anything has authenticated the - * message -- that is the whole point of the check that reports it -- so a raw newline in it - * would let an unauthenticated sender forge log lines. It is XML text content, so it can hold - * one. {@code SpnegoAuthenticator} bounds the realm it logs for the same reason and in the - * same way.

- * - * @param value The NameID to embed in a log message. - * @return A value safe to embed in a log message. - */ - protected static String sanitizeForLog(final String value) { - return sanitizeForLog(value, MAX_LOGGED_NAME_ID_LENGTH); - } - - /** - * Bounds a value to {@code maxLength} and strips its control characters so that it can be - * embedded in a log message. - * - * @param value The value to embed in a log message. - * @param maxLength The number of characters to keep before truncating. - * @return A value safe to embed in a log message. - */ - protected static String sanitizeForLog(final String value, final int maxLength) { - final String bounded = value.length() > maxLength ? value.substring(0, maxLength) + "..." : value; - return LOG_UNSAFE_PATTERN.matcher(bounded).replaceAll("?"); - } - - /** - * Returns the NameID this session was logged in with, or null when it did not come from SAML. - * - *

{@code SamlUser.getName()} is that NameID rather than a display name: it is what - * {@link #logout(FessUserBean)} passes as the {@code nameId} of the LogoutRequest it sends, - * so the IdP is expected to name the same value when the logout starts at its end.

- * - * @return The NameID of the session user, or null when nobody is logged in, when the user did - * not authenticate through SAML, or when the session cannot be reached at all. - */ - protected String getSessionSamlNameId() { - try { - final FessUserBean userBean = getSavedUserBean().orElse(null); - if (userBean != null && userBean.getFessUser() instanceof final SamlUser samlUser) { - return samlUser.getName(); - } - } catch (final Exception e) { - // this endpoint has to keep working for a request that reaches it outside a login - // scope, so being unable to look at the session means "cannot tell", not "fail" - if (logger.isDebugEnabled()) { - logger.debug("Failed to read the session user.", e); - } - } - return null; - } - - /** - * Returns the user bean held by the session. - * - *

Separate from {@link #getSessionSamlNameId()} so that a test can decide who is logged in - * without standing up a login scope; {@code /api/v2} does the same with its own handlers.

- * - * @return The user bean of the session, empty when nobody is logged in. - */ - protected OptionalThing getSavedUserBean() { - return ComponentUtil.getFessLoginAssist().getSavedUserBean(); - } - - /** - * Returns the NameID carried by the incoming LogoutRequest, or null when it cannot be read. - * - *

The message is decoded and parsed with java-saml rather than by hand. {@code /sso/logout} - * is anonymous, so hand-parsing the base64 {@code SAMLRequest} here would put an XML parser -- - * and therefore an XXE surface -- in front of an unauthenticated sender, whereas - * {@code Util.base64decodedInflated} and {@code Util.loadXML} are the hardened path the - * library uses on the same bytes a moment later. The arguments mirror - * {@code LogoutRequest.isValid()} exactly, including the allowed key transport algorithms, so - * an encrypted NameID is read here under the same restrictions it would be read under a moment - * later and this adds no decryption the message was not going to get anyway.

- * - *

It parses once. Constructing a {@link LogoutRequest} to reach the decoded XML would parse - * it a second time, because that constructor loads the document itself and then discards it, - * and a parse that fails is not free: java-saml logs the failure with its stack trace, so each - * extra parse of a message that will not parse writes another ~90 lines to the log. This - * endpoint is anonymous and, because SAML requires {@code SameSite=none}, reachable cross-site - * with the victim's cookie attached, which is exactly when this method runs -- so the second - * parse fell on the sessions an attacker targets.

- * - *

Nothing here is allowed to abort the logout. A malformed message, an {@code EncryptedID} - * with no SP private key configured to open it, an unreadable NameID: all of them mean "cannot - * tell", which {@link #isLogoutRequestForAnotherUser} turns back into the previous behaviour. - * Parsing here touches no replay cache -- only {@code isValid()} registers a message ID -- so - * reading the NameID does not make java-saml reject its own copy as a replay.

- * - * @param request The HTTP request carrying the LogoutRequest. - * @param settings The SAML settings. - * @return The NameID of the LogoutRequest, or null when it cannot be read. - */ - protected String getLogoutRequestNameId(final HttpServletRequest request, final Saml2Settings settings) { - final String logoutRequestMessage = request.getParameter("SAMLRequest"); - if (StringUtil.isBlank(logoutRequestMessage)) { - // a LogoutResponse, or no SAML message at all: there is no LogoutRequest to read - return null; - } - try { - final Document document = Util.loadXML(Util.base64decodedInflated(logoutRequestMessage)); - if (document == null) { - // Util.loadXML answers unparsable XML, and anything holding an ENTITY, with null - return null; - } - return LogoutRequest.getNameId(document, settings.getSPkey(), settings.isTrimNameIds(), - settings.getAllowedKeyTransportAlgorithms()); - } catch (final Exception e) { - if (logger.isDebugEnabled()) { - logger.debug("Failed to read the NameID of the LogoutRequest.", e); - } - return null; - } - } - - /** - * Returns whether two NameIDs identify the same user. - * - *

Deliberately more forgiving than {@code equals}, because the damage of the two comparisons - * is not symmetric: a false match only leaves today's behaviour in place, while a false - * mismatch breaks a legitimate single logout, which is silent and looks like the session simply - * refusing to end.

- * - *

Both sides are trimmed. The NameID stored at login and the NameID of the LogoutRequest - * are read from the text content of two different XML documents, and java-saml trims neither - * unless {@code saml.parsing.trim_name_ids} is turned on, which Fess leaves off; an IdP that - * pretty-prints one message and not the other would otherwise look like a different user.

- * - *

The comparison also ignores case. NameIDs that differ only in case are the same account - * at every IdP that produces them -- an email address or a UPN -- and an IdP that normalises - * case differently between its assertion and its LogoutRequest is a real deployment, not a - * hypothetical one. It costs nothing to defend against: a sender who does not know the NameID - * fails whatever the case, and one who does gains nothing from being allowed to change it.

- * - * @param sessionNameId The NameID this session was logged in with, not blank. - * @param logoutRequestNameId The NameID carried by the LogoutRequest, never null but possibly - * blank, which the session NameID cannot be and so never matches. - * @return true if both name the same user. - */ - protected boolean isSameNameId(final String sessionNameId, final String logoutRequestNameId) { - return sessionNameId.trim().equalsIgnoreCase(logoutRequestNameId.trim()); - } -} diff --git a/src/main/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticator.java b/src/main/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticator.java deleted file mode 100644 index 4f469a3cee..0000000000 --- a/src/main/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticator.java +++ /dev/null @@ -1,729 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.spnego; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Base64; -import java.util.Enumeration; -import java.util.HashSet; -import java.util.Set; -import java.util.regex.Pattern; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.codelibs.core.io.ResourceUtil; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.fess.app.web.base.login.ActionResponseCredential; -import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver; -import org.codelibs.fess.app.web.base.login.SpnegoCredential; -import org.codelibs.fess.exception.SsoLoginException; -import org.codelibs.fess.exception.SsoStateException; -import org.codelibs.fess.sso.SsoAuthenticator; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.spnego.SpnegoFilterConfig; -import org.codelibs.spnego.SpnegoHttpFilter.Constants; -import org.codelibs.spnego.SpnegoHttpServletResponse; -import org.codelibs.spnego.SpnegoPrincipal; -import org.dbflute.optional.OptionalEntity; -import org.ietf.jgss.GSSException; -import org.lastaflute.web.login.credential.LoginCredential; -import org.lastaflute.web.servlet.filter.RequestLoggingFilter; -import org.lastaflute.web.util.LaRequestUtil; -import org.lastaflute.web.util.LaResponseUtil; - -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import jakarta.servlet.FilterConfig; -import jakarta.servlet.ServletContext; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -/** - * SPNEGO (Security Provider Negotiation Protocol) authenticator implementation. - * - * This class provides Single Sign-On (SSO) authentication using the SPNEGO protocol, - * which is commonly used for Kerberos-based authentication in Windows environments. - * It handles the negotiation between client and server to establish a secure - * authentication context without requiring users to explicitly enter credentials. - * - * The authenticator supports various configuration options including delegation, - * basic authentication fallback, and localhost authentication bypass. - */ -public class SpnegoAuthenticator implements SsoAuthenticator { - - /** Logger for this class. */ - private static final Logger logger = LogManager.getLogger(SpnegoAuthenticator.class); - - /** Configuration key for enabling delegation in SPNEGO authentication. */ - protected static final String SPNEGO_ALLOW_DELEGATION = "spnego.allow.delegation"; - - /** Configuration key for the comma-separated list of additionally allowed Kerberos realms. */ - protected static final String SPNEGO_ALLOWED_REALMS = "spnego.allowed.realms"; - - /** Configuration key for allowing localhost authentication bypass. */ - protected static final String SPNEGO_ALLOW_LOCALHOST = "spnego.allow.localhost"; - - /** Configuration key for prompting NTLM authentication. */ - protected static final String SPNEGO_PROMPT_NTLM = "spnego.prompt.ntlm"; - - /** Configuration key for allowing unsecure basic authentication. */ - protected static final String SPNEGO_ALLOW_UNSECURE_BASIC = "spnego.allow.unsecure.basic"; - - /** Configuration key for allowing basic authentication. */ - protected static final String SPNEGO_ALLOW_BASIC = "spnego.allow.basic"; - - /** Configuration key for pre-authentication password. */ - protected static final String SPNEGO_PREAUTH_PASSWORD = "spnego.preauth.password"; - - /** Configuration key for pre-authentication username. */ - protected static final String SPNEGO_PREAUTH_USERNAME = "spnego.preauth.username"; - - /** Configuration key for login server module name. */ - protected static final String SPNEGO_LOGIN_SERVER_MODULE = "spnego.login.server.module"; - - /** Configuration key for login client module name. */ - protected static final String SPNEGO_LOGIN_CLIENT_MODULE = "spnego.login.client.module"; - - /** Configuration key for Kerberos configuration file path. */ - protected static final String SPNEGO_KRB5_CONF = "spnego.krb5.conf"; - - /** Configuration key for login configuration file path. */ - protected static final String SPNEGO_LOGIN_CONF = "spnego.login.conf"; - - /** Configuration key for SPNEGO logger level. */ - protected static final String SPNEGO_LOGGER_LEVEL = "spnego.logger.level"; - - /** Upper bound on the length of a client-supplied value embedded in a log message. */ - protected static final int MAX_LOGGED_REALM_LENGTH = 64; - - /** - * Characters that must not be copied verbatim into a log message. {@code \p{Cntrl}} alone is - * ASCII-only, so the Unicode break characters a log viewer still renders as a new line are - * listed explicitly. - */ - private static final Pattern LOG_UNSAFE_PATTERN = Pattern.compile("[\\p{Cntrl}\\u0085\\u2028\\u2029]"); - - /** The underlying SPNEGO authenticator instance. */ - protected volatile org.codelibs.spnego.SpnegoAuthenticator authenticator = null; - - /** - * Constructs a new SPNEGO authenticator. - */ - public SpnegoAuthenticator() { - // do nothing - } - - /** - * Initializes the SPNEGO authenticator and registers it with the SSO manager. - * This method is called automatically after dependency injection is complete. - */ - @PostConstruct - public void init() { - if (logger.isDebugEnabled()) { - logger.debug("Initializing {}", this.getClass().getSimpleName()); - } - ComponentUtil.getSsoManager().register(this); - } - - /** - * Releases the SPNEGO server credentials and login context on shutdown. - */ - @PreDestroy - public synchronized void destroy() { - if (authenticator != null) { - try { - authenticator.dispose(); - } catch (final Exception e) { - logger.warn("Failed to dispose SPNEGO authenticator.", e); - } finally { - authenticator = null; - } - } - } - - /** - * Gets or creates the SPNEGO authenticator instance. - * - * This method implements lazy initialization with synchronization to ensure - * the authenticator is only created once per JVM. Because the underlying - * SpnegoFilterConfig is a JVM-wide singleton, the configuration is cached for - * the lifetime of the process and a Fess restart is required to apply changes. - * - * @return The configured SPNEGO authenticator instance - * @throws SsoLoginException if SPNEGO initialization fails - */ - protected org.codelibs.spnego.SpnegoAuthenticator getAuthenticator() { - final org.codelibs.spnego.SpnegoAuthenticator current = authenticator; - if (current != null) { - return current; - } - synchronized (this) { - if (authenticator != null) { - return authenticator; - } - try { - // NOTE: The underlying SpnegoFilterConfig is a JVM-wide singleton, so the SPNEGO - // configuration is effectively cached for the lifetime of the process. Changes to the - // spnego.* settings therefore require a Fess restart to take effect. - final SpnegoConfig spnegoConfig = new SpnegoConfig(); - final SpnegoFilterConfig config = SpnegoFilterConfig.getInstance(spnegoConfig); - authenticator = new org.codelibs.spnego.SpnegoAuthenticator(config); - // Warn only once initialization has succeeded. A failed attempt leaves authenticator - // null and is retried on the next login, so warning before this point repeats the - // same message for every attempt, and the settings cannot matter until SPNEGO runs. - warnInsecureSettings(spnegoConfig); - return authenticator; - } catch (final Exception e) { - throw new SsoLoginException("Failed to initialize SPNEGO.", e); - } - } - } - - /** - * Logs a warning for security-sensitive settings that are effectively enabled. - * - * The coded defaults for these settings are secure, but they only apply when the key is absent - * from the system properties. An instance that stored the old, permissive values before the - * defaults were hardened keeps using them silently, so surface them at initialization time. - * - * @param config the resolved SPNEGO configuration - */ - protected void warnInsecureSettings(final SpnegoConfig config) { - if (Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_LOCALHOST))) { - logger.warn("spnego.allow.localhost=true: same-host requests are authenticated as the server OS user " - + "without any Kerberos verification. Set it to false unless you fully understand the risk."); - } - if (Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_BASIC)) - && Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_UNSEC_BASIC))) { - logger.warn( - "spnego.allow.unsecure.basic=true: basic credentials may be sent over plain HTTP. " + "Set it to false and use HTTPS."); - } - } - - /** - * Attempts to obtain login credentials using SPNEGO authentication. - * - * This method processes the HTTP request to extract and validate SPNEGO - * authentication tokens. It handles the SPNEGO handshake process and - * extracts the user principal from successful authentication. - * - * @return The login credential containing the authenticated username, - * an ActionResponseCredential for authentication challenges, - * or null if no authentication information is available - * @throws SsoLoginException if SPNEGO authentication fails - */ - @Override - public LoginCredential getLoginCredential() { - return LaRequestUtil.getOptionalRequest().map(request -> { - if (logger.isDebugEnabled()) { - logger.debug("Logging in with SPNEGO Authenticator"); - } - final HttpServletResponse response = LaResponseUtil.getResponse(); - final SpnegoHttpServletResponse spnegoResponse = new SpnegoHttpServletResponse(response); - - // The Basic path destroys the realm before a principal exists, so it has to be checked - // here, against the request. Doing it before authenticating also keeps a rejected realm - // from causing an AS-REQ to a foreign KDC. - rejectDisallowedBasicRealm(request); - - // client/caller principal - final SpnegoPrincipal principal; - try { - principal = getAuthenticator().authenticate(request, spnegoResponse); - if (logger.isDebugEnabled()) { - logger.debug("principal={}", principal); - } - } catch (final Exception e) { - final String msg = "Failed to process Authorization Header: " + maskAuthzHeader(request.getHeader(Constants.AUTHZ_HEADER)); - if (logger.isDebugEnabled()) { - // Carries the exception, so the stack trace a refusal no longer writes at warn - // level is still one log level away when an SSO failure has to be diagnosed. - logger.debug(msg, e); - } - // The library reports why the handshake failed; keep it, but not every exception - // carries a message and "null " helps nobody diagnose an SSO failure. - final String detail = e.getMessage(); - final String reason = detail == null ? msg : detail + " " + msg; - if (isHandshakeRefusal(e)) { - throw new SsoStateException(reason, e); - } - throw new SsoLoginException(reason, e); - } - - // context/auth loop not yet complete - final boolean status = spnegoResponse.isStatusSet(); - if (logger.isDebugEnabled()) { - logger.debug("isStatusSet={}", status); - } - if (status) { - // The library has already written and flushed the 401 with its WWW-Authenticate header, - // so this exception only unwinds the action. Log it at debug level to keep the normal - // SPNEGO handshake out of the application log. - return new ActionResponseCredential(() -> { - throw new RequestLoggingFilter.RequestClientErrorException("Your request is not authorized.", "401 Unauthorized", - HttpServletResponse.SC_UNAUTHORIZED).asLogging(RequestLoggingFilter.DelicateErrorLoggingLevel.DEBUG); - }); - } - - // assert - if (null == principal) { - final String msg = "Principal was null."; - if (logger.isDebugEnabled()) { - logger.debug(msg); - } - throw new SsoLoginException(msg); - } - - final String[] username = principal.getName().split("@", 2); - if (logger.isDebugEnabled()) { - logger.debug("username={}", Arrays.toString(username)); - } - if (username.length == 2 && StringUtil.isNotBlank(username[1]) && !isAllowedRealm(username[1])) { - // A refused realm is a rejected request, not a fault: /sso is anonymous, so a stack - // trace per attempt would let an unauthenticated client fill the log. - throw new SsoStateException(realmRejectedMessage(username[1])); - } - return new SpnegoCredential(username[0]); - }).orElse(null); - - } - - /** - * Tells whether a failed handshake is a request this server refused rather than a fault it - * suffered, so that it is reported by message instead of by stack trace. - * - *

{@code /sso} is anonymous, so whatever one rejected request writes to the log is what an - * unbounded loop of them writes. Three kinds of failure arrive here and all three are decided - * by what the client sent: - * - *

    - *
  • {@link UnsupportedOperationException} -- a header the library refuses to even try: a - * scheme that is neither Negotiate nor Basic, a Basic header carrying no token, Basic while - * basic authentication is not supported, or an NTLM token it cannot downgrade.
  • - *
  • {@link IllegalArgumentException} -- the token itself: - * {@code SpnegoProvider#parseAuthHeader} does not validate it, so the strict Base64 decoder - * behind {@code SpnegoAuthScheme#getToken} rejects it, and that decode runs at the top of - * {@code SpnegoProvider#negotiate} before any scheme dispatch -- so {@code "Negotiate ###"} - * reaches it whatever the {@code spnego.allow.*} settings say. A decoded Basic token with no - * {@code ':'} raises it from {@code doBasicAuth} as well.
  • - *
  • {@link GSSException} -- the token decoded but the acceptor would not take it: not a GSS - * structure at all, a replayed authenticator, a ticket for another service, clock skew.
  • - *
- * - *

The first two already reported by message. The third did not, and it is the cheapest of - * the three to provoke: a token of three Base64 characters decodes successfully and then fails - * inside {@code acceptSecContext}, which cost about ninety lines of stack per request -- some - * two orders of magnitude more than the same request without an {@code Authorization} header. - * - *

A misconfigured server also surfaces from {@code authenticate()} as {@code GSSException} - * -- a broken keytab, a wrong SPN -- and those keep being reported, by the message that names - * them ("Cannot find key of appropriate type to decrypt AP-REQ", "Checksum failed"). What they - * lose is a stack trace whose frames are the same JDK GSS internals whatever the cause, so it - * never distinguished a server fault from a client one; the message always did, and it is - * still logged at warn level for every refusal. The trace itself remains at debug level. - * - *

A genuine fault of this server cannot arrive as any of the three. Initialization failures - * are wrapped by {@link #getAuthenticator()} in a plain {@code SsoLoginException}, which is a - * {@code FessSystemException} and therefore none of these types, and it keeps its stack trace. - * - * @param e the exception the handshake failed with - * @return true when the client determined the failure - */ - protected boolean isHandshakeRefusal(final Exception e) { - return e instanceof UnsupportedOperationException || e instanceof IllegalArgumentException || e instanceof GSSException; - } - - /** - * Rejects a Basic authentication attempt whose user name names a Kerberos realm that is not - * allowed. - * - * The SPNEGO handshake carries the client's real realm in the principal, so it can be validated - * after the fact. Basic authentication cannot: the library authenticates the name the user - * typed but then builds the principal from the server realm, and KerberosPrincipal - * collapses the resulting two-realm name back to that server realm. By the time a principal - * exists the typed realm is gone, which would leave the allow list unable to govern this path. - * - * @param request the current request - * @throws SsoStateException if the realm named in the header is not allowed - */ - protected void rejectDisallowedBasicRealm(final HttpServletRequest request) { - final String realm = getBasicRealm(request.getHeader(Constants.AUTHZ_HEADER)); - if (realm != null && !isAllowedRealm(realm)) { - // A refused realm is a rejected request, not a fault: /sso is anonymous, so a stack - // trace per attempt would let an unauthenticated client fill the log. - throw new SsoStateException(realmRejectedMessage(realm)); - } - } - - /** - * Builds the message reported when a Kerberos realm is refused. - * - * @param realm the rejected realm - * @return the message, with the realm sanitized for logging - */ - protected static String realmRejectedMessage(final String realm) { - return "Kerberos realm is not allowed: realm=" + sanitizeForLog(realm) + ". Add it to " + SPNEGO_ALLOWED_REALMS - + " to accept logins from this realm."; - } - - /** - * Extracts the Kerberos realm from the user name of a Basic {@code Authorization} header. - * - * Only the user name half of the decoded token is inspected. The password is never returned and - * never logged. - * - * @param authzHeader the raw Authorization header value (may be null) - * @return the realm the client typed, or null when the header is not Basic, cannot be decoded, - * or names no realm - */ - protected static String getBasicRealm(final String authzHeader) { - if (authzHeader == null) { - return null; - } - // The scheme is separated from the token exactly the way SpnegoProvider#parseAuthHeader - // separates it: the scheme is matched case-insensitively at offset 0, any run of whitespace - // after it is skipped, and the trimmed remainder is the token. Diverging from that -- by - // splitting on a literal space, for instance -- leaves headers the library still - // authenticates (a tab as the separator, or no separator at all) resolving to no realm - // here, which silently reopens the spnego.allowed.realms bypass this check exists to close. - final int schemeLength = Constants.BASIC_HEADER.length(); - if (authzHeader.length() < schemeLength || !authzHeader.regionMatches(true, 0, Constants.BASIC_HEADER, 0, schemeLength)) { - return null; - } - int index = schemeLength; - while (index < authzHeader.length() && Character.isWhitespace(authzHeader.charAt(index))) { - index++; - } - if (index >= authzHeader.length()) { - return null; - } - final String token = authzHeader.substring(index).trim(); - if (token.isEmpty()) { - return null; - } - final byte[] decoded; - try { - decoded = Base64.getDecoder().decode(token); - } catch (final IllegalArgumentException e) { - // A malformed token is the library's to reject; do not turn it into a realm failure. - return null; - } - final String credentials = new String(decoded, StandardCharsets.UTF_8); - final int colon = credentials.indexOf(':'); - final String user = colon < 0 ? credentials : credentials.substring(0, colon); - // The library drops a NetBIOS "DOMAIN\" prefix before authenticating, so mirror it here. - final String name = user.substring(user.indexOf('\\') + 1); - // Kerberos reads the realm after the last '@', not the first: KerberosPrincipal collapses - // "alice@a@PARTNER.EXAMPLE" to name "alice@PARTNER.EXAMPLE" in realm "PARTNER.EXAMPLE", and - // the library hands the typed name straight to the login module, so PARTNER.EXAMPLE is the - // realm an AS-REQ would actually reach. Splitting on the first '@' names a realm that - // exists nowhere and that the allow list can therefore only refuse. - final int at = name.lastIndexOf('@'); - // A name ending in '@' names an empty realm, which KerberosPrincipal rejects outright, so - // there is nothing here for the allow list to decide. - if (at < 0 || at == name.length() - 1) { - return null; - } - return name.substring(at + 1); - } - - /** - * Bounds a client-supplied value and strips its control characters. - * - * A realm refused on the Basic path comes straight from an unauthenticated request and is - * written to the application log, so a raw newline would let a client forge log lines. - * - * @param value the client-supplied value - * @return a value safe to embed in a log message - */ - protected static String sanitizeForLog(final String value) { - final String bounded = value.length() > MAX_LOGGED_REALM_LENGTH ? value.substring(0, MAX_LOGGED_REALM_LENGTH) + "..." : value; - return LOG_UNSAFE_PATTERN.matcher(bounded).replaceAll("?"); - } - - /** - * Masks an Authorization header so that only its authentication scheme remains. - * - * The credential part must never reach the log: a Basic token carries the user name and the - * password, and even a short prefix of it decodes back to readable characters. The scheme that - * survives is still client-controlled, so it is bounded and sanitized like any other value - * taken from the request. The scheme ends at the first whitespace, matching how the library and - * {@link #getBasicRealm(String)} split the header. - * - * @param authzHeader the raw Authorization header value (may be null) - * @return the scheme followed by a mask, or "null" when the header is absent - */ - protected static String maskAuthzHeader(final String authzHeader) { - if (authzHeader == null) { - return "null"; - } - int index = 0; - while (index < authzHeader.length() && !Character.isWhitespace(authzHeader.charAt(index))) { - index++; - } - // Nothing before the first whitespace, or no whitespace at all, means no scheme can be - // named without echoing part of the credential. - if (index == 0 || index == authzHeader.length()) { - return "***"; - } - return sanitizeForLog(authzHeader.substring(0, index)) + " ***"; - } - - /** - * SPNEGO filter configuration implementation. - * - * This inner class provides configuration parameters for the SPNEGO filter, - * mapping system properties to SPNEGO configuration values. It handles - * various authentication settings including Kerberos configuration, - * authentication modules, and security options. - */ - protected static class SpnegoConfig implements FilterConfig { - - /** - * Constructs a new SPNEGO filter configuration. - */ - public SpnegoConfig() { - // do nothing - } - - /** - * Gets the filter name for this SPNEGO configuration. - * - * @return The fully qualified class name of SpnegoAuthenticator - */ - @Override - public String getFilterName() { - return SpnegoAuthenticator.class.getName(); - } - - /** - * Gets the servlet context. This operation is not supported. - * - * @return Never returns, always throws UnsupportedOperationException - * @throws UnsupportedOperationException Always thrown as this operation is not supported - */ - @Override - public ServletContext getServletContext() { - throw new UnsupportedOperationException("getServletContext() is not supported in SpnegoConfig"); - } - - /** - * Gets the initialization parameter value for the given parameter name. - * - * This method maps SPNEGO configuration parameter names to their corresponding - * values from system properties or default values. It handles various - * authentication and security settings for SPNEGO. - * - * @param name The name of the initialization parameter - * @return The parameter value, or null if not found - */ - @Override - public String getInitParameter(final String name) { - switch (name) { - case Constants.LOGGER_LEVEL: { - final String logLevel = getProperty(SPNEGO_LOGGER_LEVEL, StringUtil.EMPTY); - if (StringUtil.isNotBlank(logLevel)) { - if (isSupportedLoggerLevel(logLevel)) { - return logLevel; - } - logger.warn("Invalid spnego.logger.level (must be 0-7): {}. Falling back to auto-detection.", logLevel); - } - if (logger.isDebugEnabled()) { - return "3"; - } - if (logger.isInfoEnabled()) { - return "5"; - } - if (logger.isWarnEnabled()) { - return "6"; - } - // The library maps every unknown level (including "0") to INFO, so "7" (SEVERE) is - // the quietest setting it actually understands. - return "7"; - } - case Constants.LOGIN_CONF: - return getResourcePath(getProperty(SPNEGO_LOGIN_CONF, "auth_login.conf")); - case Constants.KRB5_CONF: - return getResourcePath(getProperty(SPNEGO_KRB5_CONF, "krb5.conf")); - case Constants.CLIENT_MODULE: - return getProperty(SPNEGO_LOGIN_CLIENT_MODULE, "spnego-client"); - case Constants.SERVER_MODULE: - return getProperty(SPNEGO_LOGIN_SERVER_MODULE, "spnego-server"); - case Constants.PREAUTH_USERNAME: - // Empty by default so that keytab-based server login is used when the server login - // module is configured for it (the library only uses a keytab when both preauth - // username and password are empty). - return getProperty(SPNEGO_PREAUTH_USERNAME, StringUtil.EMPTY); - case Constants.PREAUTH_PASSWORD: - return getProperty(SPNEGO_PREAUTH_PASSWORD, StringUtil.EMPTY); - case Constants.ALLOW_BASIC: - // SECURITY NOTE: Basic authentication is enabled by default for compatibility. - // For production, consider setting spnego.allow.basic to false. - return getProperty(SPNEGO_ALLOW_BASIC, "true"); - case Constants.ALLOW_UNSEC_BASIC: - // SECURITY: unsecure basic authentication is disabled by default so that basic - // credentials are never offered over plain HTTP. When false, basic auth is only - // offered over HTTPS. Enable only if you fully understand the risk. - return getProperty(SPNEGO_ALLOW_UNSECURE_BASIC, "false"); - case Constants.PROMPT_NTLM: - return getProperty(SPNEGO_PROMPT_NTLM, "true"); - case Constants.ALLOW_LOCALHOST: - // SECURITY: localhost bypass is disabled by default. When enabled, the spnego library - // authenticates same-host requests as the server OS user without Kerberos verification, - // which is unsafe behind a same-host reverse proxy. Opt in explicitly if required. - return getProperty(SPNEGO_ALLOW_LOCALHOST, "false"); - case Constants.ALLOW_DELEGATION: - return getProperty(SPNEGO_ALLOW_DELEGATION, "false"); - case null: - default: - break; - } - // NOTE: spnego.exclude.dirs is deliberately not mapped. Only SpnegoHttpFilter consumes it, - // and Fess calls SpnegoAuthenticator#authenticate directly instead of installing that - // filter, so honoring the key here would advertise an exclusion that never happens. - return null; - } - - /** - * Determines whether a configured logger level is one the SPNEGO library can consume. - * - * The library parses the value with {@link Integer#parseInt(String)}, so a value that only - * looks numeric still fails initialization once it overflows an int. Anything it does not - * recognize is mapped to INFO, which makes the documented 0-7 range the useful bound. - * - * @param value The configured logger level (not blank) - * @return true if the value can be handed to the library - */ - protected static boolean isSupportedLoggerLevel(final String value) { - try { - final int level = Integer.parseInt(value); - return level >= 0 && level <= 7; - } catch (final NumberFormatException e) { - return false; - } - } - - /** - * Gets a system property value with a default fallback. - * - * A blank value is treated as unset. The admin screen writes every spnego.* key on save, so - * clearing an input field stores an empty string rather than removing the key, and passing - * that empty string down to the library turns a simple misconfiguration into an opaque - * initialization failure. - * - * @param key The property key to look up - * @param defaultValue The default value to return if the property is not set or blank - * @return The property value or the default value - */ - protected String getProperty(final String key, final String defaultValue) { - final String value = ComponentUtil.getSystemProperties().getProperty(key); - if (StringUtil.isBlank(value)) { - return defaultValue; - } - return value; - } - - /** - * Resolves a resource path to an absolute file path. - * - * @param path The resource path to resolve - * @return The resolved absolute file path of the resource - * @throws SsoLoginException if the file cannot be found - */ - protected String getResourcePath(final String path) { - final File file = ResourceUtil.getResourceAsFileNoException(path); - if (file != null) { - return file.getAbsolutePath(); - } - throw new SsoLoginException("SPNEGO configuration file not found: " + path); - } - - /** - * Gets the names of all initialization parameters. This operation is not supported. - * - * @return Never returns, always throws UnsupportedOperationException - * @throws UnsupportedOperationException Always thrown as this operation is not supported - */ - @Override - public Enumeration getInitParameterNames() { - throw new UnsupportedOperationException("getInitParameterNames() is not supported in SpnegoConfig"); - } - - } - - /** - * Determines whether the given Kerberos realm is permitted to log in. - * The server's own realm is always allowed. - * - * @param realm the Kerberos realm extracted from the client principal - * @return true if the realm is allowed - */ - protected boolean isAllowedRealm(final String realm) { - return isAllowedRealm(realm, getAuthenticator().getServerRealm()); - } - - /** - * Determines whether the given Kerberos realm is permitted, considering the server realm and - * the comma-separated {@code spnego.allowed.realms} system property (for intentional cross-realm - * trust setups). When neither the server realm nor an allow list can be determined, the realm is - * accepted to preserve backward compatibility (a warning is logged). - * - * @param realm the Kerberos realm extracted from the client principal - * @param serverRealm the Kerberos realm of the SPNEGO server principal (may be blank) - * @return true if the realm is allowed - */ - protected boolean isAllowedRealm(final String realm, final String serverRealm) { - final Set allowedRealms = new HashSet<>(); - if (StringUtil.isNotBlank(serverRealm)) { - allowedRealms.add(serverRealm); - } - final String configured = ComponentUtil.getSystemProperties().getProperty(SPNEGO_ALLOWED_REALMS, StringUtil.EMPTY); - if (StringUtil.isNotBlank(configured)) { - for (final String r : configured.split(",")) { - if (StringUtil.isNotBlank(r)) { - allowedRealms.add(r.trim()); - } - } - } - if (allowedRealms.isEmpty()) { - logger.warn("No allowed Kerberos realm could be determined; accepting realm={} without validation.", sanitizeForLog(realm)); - return true; - } - return allowedRealms.stream().anyMatch(r -> r.equalsIgnoreCase(realm)); - } - - /** - * Resolves the SPNEGO credential to a user entity. - * - * This method handles the resolution of SPNEGO credentials by checking - * if the user is an admin user or needs to be authenticated through LDAP. - * - * @param resolver The credential resolver to use for user lookup - */ - @Override - public void resolveCredential(final LoginCredentialResolver resolver) { - resolver.resolve(SpnegoCredential.class, credential -> { - final String username = credential.getUserId(); - if (!ComponentUtil.getFessConfig().isAdminUser(username)) { - return ComponentUtil.getLdapManager().login(username); - } - return OptionalEntity.empty(); - }); - } - -} diff --git a/src/main/resources/fess_sso++.xml b/src/main/resources/fess_sso++.xml deleted file mode 100644 index 0c79071f3a..0000000000 --- a/src/main/resources/fess_sso++.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/main/resources/fess_sso.xml b/src/main/resources/fess_sso.xml index 6f2121732a..c497bb0ebf 100644 --- a/src/main/resources/fess_sso.xml +++ b/src/main/resources/fess_sso.xml @@ -2,6 +2,22 @@ + diff --git a/src/test/java/org/codelibs/fess/app/web/base/login/EntraIdUserPermissionTest.java b/src/test/java/org/codelibs/fess/app/web/base/login/EntraIdUserPermissionTest.java deleted file mode 100644 index ce45e8d400..0000000000 --- a/src/test/java/org/codelibs/fess/app/web/base/login/EntraIdUserPermissionTest.java +++ /dev/null @@ -1,621 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.app.web.base.login; - -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser; -import org.codelibs.fess.helper.ActivityHelper; -import org.codelibs.fess.helper.SystemHelper; -import org.codelibs.fess.mylasta.action.FessUserBean; -import org.codelibs.fess.sso.entraid.EntraIdAuthenticator; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.dbflute.optional.OptionalThing; -import org.junit.jupiter.api.Test; - -import com.microsoft.aad.msal4j.IAccount; -import com.microsoft.aad.msal4j.IAuthenticationResult; -import com.microsoft.aad.msal4j.ITenantProfile; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.PlainJWT; - -public class EntraIdUserPermissionTest extends UnitFessTestCase { - - /** The user's object id in the tenant, as the ID token's {@code oid} claim carries it. */ - private static final String OBJECT_ID = "3f7a1c9e-0b52-4d18-9a6c-2e5b8d41f0aa"; - - /** An ID token carrying {@link #OBJECT_ID}, in the shape MSAL4J hands back. */ - private static final String ID_TOKEN = new PlainJWT(new JWTClaimsSet.Builder().claim("oid", OBJECT_ID).build()).serialize(); - - private static IAuthenticationResult authResult() { - return authResult(new Date(Long.MAX_VALUE), "access-token"); - } - - private static IAuthenticationResult authResult(final Date expiresOn, final String accessToken) { - return authResult(expiresOn, accessToken, ID_TOKEN); - } - - private static IAuthenticationResult authResult(final Date expiresOn, final String accessToken, final String idToken) { - final IAccount account = new IAccount() { - private static final long serialVersionUID = 1L; - - @Override - public String homeAccountId() { - return "home-account-id"; - } - - @Override - public String environment() { - return "login.microsoftonline.com"; - } - - @Override - public String username() { - return "taro@contoso.onmicrosoft.com"; - } - - @Override - public Map getTenantProfiles() { - return Collections.emptyMap(); - } - }; - return new IAuthenticationResult() { - private static final long serialVersionUID = 1L; - - @Override - public String accessToken() { - return accessToken; - } - - @Override - public String idToken() { - return idToken; - } - - @Override - public IAccount account() { - return account; - } - - @Override - public ITenantProfile tenantProfile() { - return null; - } - - @Override - public String environment() { - return "login.microsoftonline.com"; - } - - @Override - public String scopes() { - return "https://graph.microsoft.com/.default"; - } - - @Override - public Date expiresOnDate() { - return expiresOn; - } - }; - } - - /** - * Builds an EntraIdUser without letting its constructor talk to Microsoft Graph. - */ - private EntraIdUser newUser() { - return newUser(authResult()); - } - - private EntraIdUser newUser(final IAuthenticationResult authResult) { - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // the test drives setGroups/setRoles itself - } - }, EntraIdAuthenticator.class.getCanonicalName()); - return new EntraIdUser(authResult); - } - - @Test - public void test_getPermissions_doesNotCollapseAGroupNameOnABackslash() { - // getCanonicalLdapName drops everything up to the first backslash, because a name a user - // types at login may be NetBIOS-qualified as DOMAIN\name. An identity provider's group - // name is not, so running it through that truncation let one group's name produce another - // group's permission: with entraid.permission.fields=displayName, a tenant user who can - // create a security group -- the Entra ID default -- names it "x\finance" and receives - // the permission of the unrelated group "finance", and with it every document that group - // can read. Verified end to end against a live tenant before this fix. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdUser user = newUser(); - user.setGroups(new String[] { "x\\finance" }); - user.setRoles(new String[] { "y\\admin" }); - - final List permissions = Arrays.asList(user.getPermissions()); - - assertTrue(permissions.toString(), permissions.contains("2x\\finance")); - assertFalse(permissions.toString(), permissions.contains("2finance")); - assertTrue(permissions.toString(), permissions.contains("Ry\\admin")); - assertFalse(permissions.toString(), permissions.contains("Radmin")); - } - - @Test - public void test_getPermissions_doesNotCollapseAUserNameOnABackslash() { - // The same truncation applied to the name the provider asserted for the user itself. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdUser user = newUser(); - user.setGroups(new String[0]); - user.setRoles(new String[0]); - - final List permissions = Arrays.asList(user.getPermissions()); - - // The account username is taro@contoso.onmicrosoft.com, so nothing is truncated here; the - // assertion that matters is that the value arrives whole and prefixed. - assertTrue(permissions.toString(), permissions.contains("1taro@contoso.onmicrosoft.com")); - assertTrue(permissions.toString(), permissions.contains("1" + OBJECT_ID)); - } - - @Test - public void test_getPermissions_namesTheUserByTheObjectIdInTheIdToken() { - // Microsoft Graph names a user by the object id, so that is the value a crawler writes - // into the role field of a document the user owns. homeAccountId() is MSAL4J's own - // account key -- "." -- and a permission built from it matches no - // such role, so the user never saw a document granted to them by object id. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdUser user = newUser(); - user.setGroups(new String[0]); - user.setRoles(new String[0]); - - final List permissions = Arrays.asList(user.getPermissions()); - - assertTrue(permissions.toString(), permissions.contains("1" + OBJECT_ID)); - assertFalse(permissions.toString(), permissions.contains("1home-account-id")); - } - - @Test - public void test_getPermissions_grantsNoObjectIdPermissionWhenTheIdTokenCarriesNone() { - // A missing or unreadable oid claim must drop the permission, not encode a literal "null" - // -- which is not blank, so the trailing filter would keep it, and a document whose role - // field held it would be readable by every such session. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdUser user = newUser(authResult(new Date(Long.MAX_VALUE), "access-token", "not-a-jwt")); - user.setGroups(new String[0]); - user.setRoles(new String[0]); - - final List permissions = Arrays.asList(user.getPermissions()); - - assertFalse(permissions.toString(), permissions.stream().anyMatch(p -> p.contains("null"))); - assertTrue(permissions.toString(), permissions.contains("1taro@contoso.onmicrosoft.com")); - } - - @Test - public void test_getPermissions_doesNotPinAStaleValueWhenTheAsyncLookupLands() throws Exception { - // The membership resolution scheduled at login runs on a TimeoutManager thread while the - // user is already logged in and searching. getPermissions() is a check-then-act -- read - // `permissions == null`, read `groups`, write `permissions` -- so a reader that started - // before that task lands can finish after it and overwrite the fresh value with one - // computed from the direct groups alone. Nothing sets `permissions` back to null after - // that, so the parent group permissions stay missing for the rest of the session. - final CountDownLatch readerIsInside = new CountDownLatch(1); - final CountDownLatch asyncTaskIsDone = new CountDownLatch(1); - ComponentUtil.register(new SystemHelper() { - @Override - public String getSearchRoleByDirectoryGroup(final String name) { - if ("direct-group".equals(name)) { - // The reader has read `groups` and is now mid-computation. - readerIsInside.countDown(); - try { - asyncTaskIsDone.await(10L, TimeUnit.SECONDS); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - return super.getSearchRoleByDirectoryGroup(name); - } - }, "systemHelper"); - - final EntraIdUser user = newUser(); - user.setGroups(new String[] { "direct-group" }); - user.setRoles(new String[0]); - - final Thread reader = new Thread(() -> user.getPermissions()); - reader.start(); - assertTrue(readerIsInside.await(10L, TimeUnit.SECONDS)); - - // What the scheduled updateMemberOf task does once the parent groups arrive, on its own - // thread so that it can be made to wait for the reader rather than deadlocking with it. - final Thread asyncLookup = new Thread(() -> { - user.setGroups(new String[] { "direct-group", "parent-group" }); - user.setRoles(new String[0]); - user.resetPermissions(); - }); - asyncLookup.start(); - // Give the async task time to get as far as it is able to before the reader finishes. - Thread.sleep(200L); - - asyncTaskIsDone.countDown(); - reader.join(10000L); - asyncLookup.join(10000L); - - final String[] permissions = user.getPermissions(); - assertTrue("parent-group missing from " + Arrays.toString(permissions), - Arrays.stream(permissions).anyMatch(p -> p.contains("parent-group"))); - } - - @Test - public void test_refresh_renewsOnceWhenConcurrentRequestsShareTheUser() throws Exception { - // Lastaflute keeps the FessUserBean -- and therefore one EntraIdUser -- as a session - // attribute, and FessBaseAction.godHandPrologue calls refresh() on every action request, - // so all the requests a session has in flight arrive in the REFRESH_MARGIN window - // together. Each of them used to see a renewed access token and run updateMemberOf, which - // is a synchronous Microsoft Graph GET /me/memberOf on a request thread plus another - // scheduled parent group lookup. updateMemberOf itself now runs off the request thread, - // but scheduling it twice per rollover would still double the eventual Graph traffic -- - // exactly what the per-request guard was added to remove. scheduleUpdateMemberOf is the - // seam refresh() now calls, so it is what proves the guard suppressed the second call. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - // Inside REFRESH_MARGIN, so refresh() really attempts the silent acquisition. - final IAuthenticationResult initial = authResult(new Date(now + 30 * 1000L), "access-token"); - - final AtomicInteger scheduleCalls = new AtomicInteger(); - final CountDownLatch winnerIsAcquiring = new CountDownLatch(1); - final CountDownLatch loserIsDone = new CountDownLatch(1); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - scheduleCalls.incrementAndGet(); - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - // Hold the acquisition open the way a real MSAL4J round trip does, so the second - // request reaches refresh() while this one is still inside it. - winnerIsAcquiring.countDown(); - try { - loserIsDone.await(10L, TimeUnit.SECONDS); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - } - return authResult(new Date(now + 30 * 1000L), "renewed-access-token"); - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdUser(initial); - // The constructor schedules its own resolution once; only what refresh() adds is under test. - scheduleCalls.set(0); - - final AtomicBoolean winnerResult = new AtomicBoolean(); - final Thread winner = new Thread(() -> winnerResult.set(user.refresh())); - winner.start(); - assertTrue(winnerIsAcquiring.await(10L, TimeUnit.SECONDS)); - - // The session's second concurrent request. Its token has not expired, so it must be let - // through rather than blocked behind the acquisition, and it must not renew again. If the - // refreshing CAS guard in refresh() were removed, this second call would reach - // refreshTokenSilently (and, since the stub always answers "renewed", scheduleUpdateMemberOf) - // concurrently with the winner instead of returning immediately, taking the count below to 2. - assertTrue(user.refresh()); - loserIsDone.countDown(); - winner.join(10000L); - - assertTrue(winnerResult.get()); - assertEquals(1, scheduleCalls.get(), "a concurrent refresh must not schedule a second Microsoft Graph round trip"); - // Last-writer-wins used to be able to leave the older of the two results in place. - assertEquals("renewed-access-token", user.getAuthenticationResult().accessToken()); - } - - @Test - public void test_refresh_stillRenewsOnEveryRollover() throws Exception { - // The counterpart of the test above: the guard must only suppress a *concurrent* renewal. - // A sequential refresh has to keep re-reading the directory, otherwise a session would - // never pick up a group change again, and the flag has to be released on the way out. - // scheduleUpdateMemberOf is the seam refresh() now calls per rollover. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final IAuthenticationResult initial = authResult(new Date(now + 30 * 1000L), "access-token"); - - final AtomicInteger scheduleCalls = new AtomicInteger(); - final AtomicReference next = new AtomicReference<>(); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - scheduleCalls.incrementAndGet(); - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - return next.get(); - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdUser(initial); - scheduleCalls.set(0); - - next.set(authResult(new Date(now + 30 * 1000L), "second-access-token")); - assertTrue(user.refresh()); - assertEquals(1, scheduleCalls.get(), "the first rollover must re-read the directory"); - - next.set(authResult(new Date(now + 30 * 1000L), "third-access-token")); - assertTrue(user.refresh()); - assertEquals(2, scheduleCalls.get(), "the guard must be released once the acquisition is over"); - assertEquals("third-access-token", user.getAuthenticationResult().accessToken()); - } - - @Test - public void test_updateMemberOf_resetsThePermissionsCacheOnceGroupsResolve() throws Exception { - // Under the new PENDING window, getPermissions() is very likely to be computed once before - // updateMemberOf lands -- groups is still null, so only the user-scoped permission gets - // cached. resetPermissions() inside updateMemberOf is now the only thing that clears that - // cache once the real groups arrive; refresh() no longer calls it separately. If it - // silently stopped firing, this stale, user-scoped-only array would pin for the rest of - // the session. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - // updateMemberOf calls permissionChanged() at the end, and test_app.xml does not register - // a real activityHelper (production's app.xml does). - ComponentUtil.register(new ActivityHelper() { - @Override - public void permissionChanged(final OptionalThing user) { - // no-op - } - }, "activityHelper"); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // keep the constructor off Graph; this test drives updateMemberOf itself - } - }, EntraIdAuthenticator.class.getCanonicalName()); - final EntraIdUser user = new EntraIdUser(authResult()); - - final String[] beforePermissions = user.getPermissions(); - assertFalse("resolved-group must not be present before updateMemberOf runs: " + Arrays.toString(beforePermissions), - Arrays.stream(beforePermissions).anyMatch(p -> p.contains("resolved-group"))); - - final EntraIdAuthenticator resolvingAuthenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("resolved-group"); - return true; - } - }; - resolvingAuthenticator.updateMemberOf(user); - - final String[] afterPermissions = user.getPermissions(); - assertTrue("resolved-group missing from " + Arrays.toString(afterPermissions), - Arrays.stream(afterPermissions).anyMatch(p -> p.contains("resolved-group"))); - } - - /** - * Registers a SystemHelper whose clock the test drives, the way EntraIdAuthenticatorTest does. - */ - private void registerClock(final AtomicLong clock) { - ComponentUtil.register(new SystemHelper() { - @Override - public long getCurrentTimeAsLong() { - return clock.get(); - } - }, "systemHelper"); - } - - @Test - public void test_refresh_attemptsARenewalWhenTheTokenHasExpired() { - // FessBaseAction.godHandPrologue discards this result, so returning false without asking - // MSAL4J for anything never ended the session: it left it holding a dead access token and - // taking the same early exit on every later request, which is what stopped its group - // memberships from ever being re-read again. MSAL4J's silent flow spends the cached - // refresh token, which outlives the access token by hours, so an expired access token is - // precisely the case worth one attempt. - final AtomicLong clock = new AtomicLong(1_700_000_000_000L); - registerClock(clock); - final AtomicInteger acquisitions = new AtomicInteger(); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // the constructor must not reach Microsoft Graph. Overriding the scheduling and - // not updateMemberOf: the base implementation hands a real task to TimeoutManager, - // so overriding only the body still leaves a timer thread racing this test. - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - acquisitions.incrementAndGet(); - return null; - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token")); - - // The acquisition failed, so the token really is dead and refresh() says so. - assertFalse(user.refresh()); - assertEquals(1, acquisitions.get(), "an expired access token must not be given up on without asking MSAL4J"); - } - - @Test - public void test_refresh_recoversASessionWhoseTokenExpired() { - // The user was idle across the expiry -- with REFRESH_MARGIN in place their last request - // can easily have fallen before the renewal window -- and comes back. The cached refresh - // token is still good, so the session carries on with a live token and re-read groups. - final AtomicLong clock = new AtomicLong(1_700_000_000_000L); - registerClock(clock); - final AtomicInteger memberOfCalls = new AtomicInteger(); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // The renewal schedules the re-resolution rather than running it: refresh() is on - // a request thread and updateMemberOf reaches Microsoft Graph. Counting the - // scheduling is what pins that the re-read is requested at all. - memberOfCalls.incrementAndGet(); - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - return authResult(new Date(clock.get() + 60 * 60 * 1000L), "renewed-access-token"); - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token")); - // The constructor schedules the first resolution; only what refresh() adds is under test. - memberOfCalls.set(0); - - assertTrue(user.refresh()); - assertEquals("renewed-access-token", user.getAuthenticationResult().accessToken()); - assertEquals(1, memberOfCalls.get(), "a recovered session must re-read its group memberships"); - } - - @Test - public void test_refresh_holdsOffAFailingRenewalUntilTheThrottleLapses() { - // A revoked refresh token, a disabled account, and an account a logout on another session - // evicted from the shared MSAL4J cache all fail for good, and refresh() runs on every - // action request. Retrying unconditionally would put back exactly the per-request round - // trip REFRESH_MARGIN was introduced to remove. - final AtomicLong clock = new AtomicLong(1_700_000_000_000L); - registerClock(clock); - final AtomicInteger acquisitions = new AtomicInteger(); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // the constructor must not reach Microsoft Graph. Overriding the scheduling and - // not updateMemberOf: the base implementation hands a real task to TimeoutManager, - // so overriding only the body still leaves a timer thread racing this test. - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - acquisitions.incrementAndGet(); - return null; - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token")); - - assertFalse(user.refresh()); - assertEquals(1, acquisitions.get(), "the first request after the expiry must attempt a renewal"); - - // The rest of the requests this session makes inside the interval. - assertFalse(user.refresh()); - clock.addAndGet(EntraIdUser.RENEWAL_THROTTLE_INTERVAL - 1L); - assertFalse(user.refresh()); - assertEquals(1, acquisitions.get(), "a renewal that failed must not be retried on every request"); - - // ... and the first one after it. - clock.addAndGet(1L); - assertFalse(user.refresh()); - assertEquals(2, acquisitions.get(), "the throttle must lapse rather than give up for good"); - } - - @Test - public void test_refresh_doesNotStampedeWhenConcurrentRequestsFindAnExpiredToken() throws Exception { - // The concurrency guard has to cover the expired token as well, not just the renewal - // window: godHandPrologue calls refresh() on every action request, so the requests a - // session has in flight when it comes back after the expiry arrive here together, and - // each of them would otherwise run its own acquisition and its own synchronous Microsoft - // Graph call behind updateMemberOf. - final AtomicLong clock = new AtomicLong(1_700_000_000_000L); - registerClock(clock); - final AtomicInteger acquisitions = new AtomicInteger(); - final AtomicInteger memberOfCalls = new AtomicInteger(); - final CountDownLatch winnerIsAcquiring = new CountDownLatch(1); - final CountDownLatch loserIsDone = new CountDownLatch(1); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // The scheduling is what refresh() does -- it runs on a request thread and - // updateMemberOf reaches Microsoft Graph. Counting the base implementation's - // TimeoutManager task instead would race this assertion. - memberOfCalls.incrementAndGet(); - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - acquisitions.incrementAndGet(); - // Hold the acquisition open the way a real MSAL4J round trip does, so the second - // request reaches refresh() while this one is still inside it. - winnerIsAcquiring.countDown(); - try { - loserIsDone.await(10L, TimeUnit.SECONDS); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - } - return authResult(new Date(clock.get() + 60 * 60 * 1000L), "renewed-access-token"); - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token")); - memberOfCalls.set(0); - - final AtomicBoolean winnerResult = new AtomicBoolean(); - final Thread winner = new Thread(() -> winnerResult.set(user.refresh())); - winner.start(); - assertTrue(winnerIsAcquiring.await(10L, TimeUnit.SECONDS)); - - // The session's second concurrent request. It holds nothing valid, so it reports that, - // but it must not start a second acquisition of its own. - assertFalse(user.refresh()); - assertEquals(1, acquisitions.get(), "a concurrent refresh must not start a second silent acquisition"); - loserIsDone.countDown(); - winner.join(10000L); - - assertTrue(winnerResult.get()); - assertEquals(1, memberOfCalls.get(), "a concurrent refresh must not make a second Microsoft Graph round trip"); - assertEquals("renewed-access-token", user.getAuthenticationResult().accessToken()); - } - - @Test - public void test_refresh_holdsOffAfterAnExceptionToo() { - // The exception path has to back off as well, otherwise the failure it now reports at - // WARN -- refreshTokenSilently swallows its own, so in production this is updateMemberOf - // or the component lookup throwing -- is written once per request rather than once per - // interval, which is exactly the noise the throttle is there to prevent. - final AtomicLong clock = new AtomicLong(1_700_000_000_000L); - registerClock(clock); - final AtomicInteger acquisitions = new AtomicInteger(); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // the constructor must not reach Microsoft Graph. Overriding the scheduling and - // not updateMemberOf: the base implementation hands a real task to TimeoutManager, - // so overriding only the body still leaves a timer thread racing this test. - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - acquisitions.incrementAndGet(); - throw new IllegalStateException("the directory could not be reached"); - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token")); - - assertFalse(user.refresh()); - assertFalse(user.refresh()); - assertEquals(1, acquisitions.get(), "a renewal that threw must not be retried on every request"); - - clock.addAndGet(EntraIdUser.RENEWAL_THROTTLE_INTERVAL); - assertFalse(user.refresh()); - assertEquals(2, acquisitions.get(), "the throttle must lapse rather than give up for good"); - } -} diff --git a/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java b/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java index cf8df94012..24be2be599 100644 --- a/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java +++ b/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java @@ -351,12 +351,6 @@ public void test_lazyUpgrade_upgradeDisabled_doesNotInvokeUpdateAtAll() throws E // needsLoginSessionSyncCheck // --------------------------------------------------------------------- - @Test - public void test_needsSyncCheck_samlUser_staleCheckTime_skipsCheck() { - final FessUserBean bean = new FessUserBean(newSamlUser("saml-admin")); - assertFalse(loginAssist.needsLoginSessionSyncCheck(bean, OptionalThing.of(STALE_CHECK_DT), NOW)); - } - @Test public void test_needsSyncCheck_ldapUser_staleCheckTime_skipsCheck() { final FessUserBean bean = new FessUserBean(new LdapUser(new Hashtable<>(), "ldap-admin")); @@ -364,15 +358,15 @@ public void test_needsSyncCheck_ldapUser_staleCheckTime_skipsCheck() { } @Test - public void test_needsSyncCheck_openIdUser_staleCheckTime_skipsCheck() { - final FessUserBean bean = new FessUserBean(newOpenIdUser("oidc-admin")); + public void test_needsSyncCheck_ssoUser_staleCheckTime_skipsCheck() { + final FessUserBean bean = new FessUserBean(newSsoUser("sso-admin")); assertFalse(loginAssist.needsLoginSessionSyncCheck(bean, OptionalThing.of(STALE_CHECK_DT), NOW)); } @Test public void test_needsSyncCheck_externalUser_neverCheckedYet_skipsCheck() { // super returns true for "no check yet"; the external-user guard must run first. - final FessUserBean bean = new FessUserBean(newSamlUser("saml-admin")); + final FessUserBean bean = new FessUserBean(newSsoUser("sso-admin")); assertFalse(loginAssist.needsLoginSessionSyncCheck(bean, OptionalThing.empty(), NOW)); } @@ -415,7 +409,7 @@ public void test_needsSyncCheck_nullBackedUserBean_skipsCheck() { public void test_syncCheckLoginSession_externalUser_keepsSessionWithoutQuery() { final ExposedLoginAssist assist = newExposedAssist(); final CountingUserBhv bhv = installCountingUserBhv(assist, null); - final FessUserBean bean = new FessUserBean(newSamlUser("saml-admin")); + final FessUserBean bean = new FessUserBean(newSsoUser("sso-admin")); bean.manageLastestSyncCheckTime(STALE_CHECK_DT); assertTrue(assist.callSyncCheckLoginSession(bean)); @@ -511,12 +505,37 @@ public void test_container_canBuildTheLoginManager() { /** Well within the 300-second default sync-check interval. */ private static final LocalDateTime FRESH_CHECK_DT = NOW.minusSeconds(10); - private static SamlCredential.SamlUser newSamlUser(final String nameId) { - return new SamlCredential.SamlUser(nameId, "session-index", null, null, null, new String[0], new String[] { "admin" }); - } + /** + * A user an SSO authenticator hands out. The guard under test asks only whether the bean holds + * the local {@link User} document, so what stands in for SamlUser and OpenIdUser -- which moved + * to the fess-sso-saml and fess-sso-oidc plugins with their authenticators -- is any other + * FessUser. This one is declared here rather than borrowed from another package so that the + * test keeps asserting the guard and not a plugin's class hierarchy. + */ + private static FessUser newSsoUser(final String name) { + return new FessUser() { + private static final long serialVersionUID = 1L; + + @Override + public String getName() { + return name; + } - private static OpenIdConnectCredential.OpenIdUser newOpenIdUser(final String name) { - return new OpenIdConnectCredential.OpenIdUser(name, new String[0], new String[] { "admin" }); + @Override + public String[] getRoleNames() { + return new String[] { "admin" }; + } + + @Override + public String[] getGroupNames() { + return new String[0]; + } + + @Override + public String[] getPermissions() { + return new String[] { "Radmin" }; + } + }; } private static User newLocalUser(final String name) { diff --git a/src/test/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredentialTest.java b/src/test/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredentialTest.java deleted file mode 100644 index f5232f6e30..0000000000 --- a/src/test/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredentialTest.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.app.web.base.login; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; - -/** - * Unit tests for {@link OpenIdConnectCredential}, covering the shapes an OpenID provider can give - * the {@code groups} claim and how {@code oic.default.groups} applies to each of them. - */ -public class OpenIdConnectCredentialTest extends UnitFessTestCase { - - private static final String DEFAULT_GROUPS_KEY = "oic.default.groups"; - - @Override - protected void setUp(final TestInfo testInfo) throws Exception { - super.setUp(testInfo); - // The system properties component outlives a single test class, so the key is set on the one - // the code actually reads and removed again below rather than swapped for a fresh instance. - ComponentUtil.getSystemProperties().setProperty(DEFAULT_GROUPS_KEY, "fallback"); - } - - @Override - protected void tearDown(final TestInfo testInfo) throws Exception { - ComponentUtil.getSystemProperties().remove(DEFAULT_GROUPS_KEY); - super.tearDown(testInfo); - } - - private static String[] groupsOf(final Object claim) { - final Map attributes = new HashMap<>(); - attributes.put("email", "user@example.com"); - if (claim != null) { - attributes.put("groups", claim); - } - return new OpenIdConnectCredential(attributes).getUserGroups(); - } - - @Test - public void test_getUserGroups_fromArrayClaim() { - assertEquals(List.of("dev", "sales"), List.of(groupsOf(List.of("dev", "sales")))); - } - - @Test - public void test_getUserGroups_fromSingleValuedStringClaim() { - // Some providers emit a single-valued claim as a bare string rather than a one-element array. - // That group used to be dropped and oic.default.groups substituted for it. - assertEquals(List.of("dev"), List.of(groupsOf("dev"))); - } - - @Test - public void test_getUserGroups_fromSingleValuedStringClaim_isTrimmed() { - assertEquals(List.of("dev"), List.of(groupsOf(" dev "))); - } - - @Test - public void test_getUserGroups_fromBlankStringClaim() { - // The claim was sent, so the default does not apply -- the same rule as an empty array. - assertEquals(0, groupsOf("").length); - assertEquals(0, groupsOf(" ").length); - } - - @Test - public void test_getUserGroups_fromEmptyArrayClaim() { - assertEquals(0, groupsOf(List.of()).length); - } - - @Test - public void test_getUserGroups_withoutClaimUsesTheDefault() { - assertEquals(List.of("fallback"), List.of(groupsOf(null))); - } - - @Test - public void test_getUserId_fromEmailClaim() { - final Map attributes = new HashMap<>(); - attributes.put("email", "user@example.com"); - assertEquals("user@example.com", new OpenIdConnectCredential(attributes).getUserId()); - } - - @Test - public void test_getUserId_withoutEmailClaim() { - assertNull(new OpenIdConnectCredential(new HashMap<>()).getUserId()); - } -} diff --git a/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java b/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java index 21e9e2d816..2b6049a5dc 100644 --- a/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java +++ b/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java @@ -146,7 +146,8 @@ private void setValueToObject(Object obj, String name, Object value) { * silently, because the shadowed call neither fails nor logs. Registering {@code systemHelper} * that way left {@code SamlAuthenticatorTest} holding the real clock instead of the fake one it * installs, so its two tests that move the clock failed whenever this class happened to run - * before them in the same surefire fork. What {@code ComponentUtil} holds is cleared by + * before them in the same surefire fork. That test has since moved to the fess-sso-saml plugin, + * which changes nothing about the trap: it is a property of the shared container. What {@code ComponentUtil} holds is cleared by * {@code UnitFessTestCase#tearDown}, so nothing outlives a test method and the registration is * simply repeated per method.

* diff --git a/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java b/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java index 3778c1fe2a..7c9f733570 100644 --- a/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java +++ b/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java @@ -15,10 +15,12 @@ */ package org.codelibs.fess.sso; +import org.apache.logging.log4j.Level; import org.codelibs.fess.Constants; import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver; import org.codelibs.fess.mylasta.action.FessUserBean; import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.unit.LogCapturingAppender; import org.codelibs.fess.unit.UnitFessTestCase; import org.codelibs.fess.util.ComponentUtil; import org.junit.jupiter.api.Test; @@ -466,6 +468,133 @@ protected String getSsoType() { assertEquals("entraiduser", ((TestLoginCredential) credential).username); } + // Test the report for an sso.type no installed plugin serves + /** + * The failure this reports used to be silent: a configured sso.type whose authenticator is not + * registered made every caller answer null, SsoAction turned that into errors.sso_login_error + * and a redirect, and nothing was logged above debug. The whole symptom was a GET /sso/ that + * answered 302 to /login/, which no amount of reading the configuration explains -- the + * configuration is right and a plugin is missing. Splitting the authenticators out of core made + * that the ordinary state of an upgraded installation. + */ + @Test + public void test_getAuthenticator_unservedType_warnsNamingTheComponentAndThePlugin() { + currentSsoType = "saml"; + ssoManager = newManager(); + + final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class); + try { + assertNull(ssoManager.getAuthenticator(), "no samlAuthenticator is registered here"); + assertEquals(1, capture.warnings().size()); + final String warning = capture.warnings().get(0); + assertTrue(warning.contains("samlAuthenticator"), warning); + assertTrue(warning.contains("sso.type=saml"), warning); + assertTrue(warning.contains("fess-sso-saml"), warning); + // ERROR is a notification trigger in Fess, and an unfinished installation is not a fault. + assertTrue(capture.errors().isEmpty(), "must not be reported at ERROR: " + capture.errors()); + } finally { + capture.detach(); + } + } + + /** + * /sso/ is anonymous and the miss is hit on every visit, so a warning per attempt is a log an + * unauthenticated client can fill. + */ + @Test + public void test_getAuthenticator_unservedType_warnsOnlyOncePerType() { + currentSsoType = "saml"; + ssoManager = newManager(); + + final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class); + try { + for (int i = 0; i < 5; i++) { + assertNull(ssoManager.getAuthenticator(), "attempt " + i); + } + assertEquals(1, capture.warnings().size()); + + // A different type is a different miss and is worth its own line. + currentSsoType = "spnego"; + assertNull(ssoManager.getAuthenticator(), "no spnegoAuthenticator is registered here"); + assertEquals(2, capture.warnings().size()); + assertTrue(capture.warnings().get(1).contains("sso.type=spnego"), capture.warnings().get(1)); + } finally { + capture.detach(); + } + } + + /** + * The legacy type maps to entraid before the component name is built, so the plugin the warning + * names is the one that actually serves it. + */ + @Test + public void test_getAuthenticator_unservedAadType_namesTheEntraidPlugin() { + currentSsoType = "aad"; + ssoManager = newManager(); + + final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class); + try { + assertNull(ssoManager.getAuthenticator(), "no entraidAuthenticator is registered here"); + assertEquals(1, capture.warnings().size()); + final String warning = capture.warnings().get(0); + assertTrue(warning.contains("entraidAuthenticator"), warning); + assertTrue(warning.contains("fess-sso-entraid"), warning); + } finally { + capture.detach(); + } + } + + /** + * An installation that does not use SSO must stay quiet: /sso/ is reachable whether or not it + * is configured, so a warning here would be a line per anonymous request on a deployment that + * has nothing wrong with it. + */ + @Test + public void test_getAuthenticator_ssoNotConfigured_saysNothing() { + final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class.getName(), Level.WARN); + try { + for (final String type : new String[] { Constants.NONE, "", " ", null }) { + currentSsoType = type; + ssoManager = newManager(); + assertNull(ssoManager.getAuthenticator(), "type=" + type); + } + assertTrue(capture.events().isEmpty(), "nothing to report for an unconfigured sso.type: " + capture.warnings()); + } finally { + capture.detach(); + } + } + + /** + * Core must not ship a fess_sso++.xml. The plugins contribute their authenticators through a + * file of that name, and the ++ suffix merges every copy on the classpath: one in the war and + * one in a plugin jar define the same component twice, which makes getComponent throw + * TooManyRegistrationComponentException and runs the @PostConstruct that calls + * {@link SsoManager#register} twice. Overriding one from the other is not available either -- + * a redefinition file is named fess_sso+<component>.xml, and a base path containing "+" + * is what RedefinableComponentTagHandler.redefine() refuses -- so the only workable division is + * for core to ship none. fess_sso.xml itself has to stay: it is what the plugins merge into. + */ + @Test + public void test_coreShipsTheBaseFileAndNoPlusPlusFile() { + final ClassLoader loader = getClass().getClassLoader(); + assertNotNull(loader.getResource("fess_sso.xml"), "fess_sso.xml is the file the plugins merge into"); + assertNull(loader.getResource("fess_sso++.xml"), + "fess_sso++.xml belongs to the fess-sso-* plugins; a copy in core collides with theirs. " + + "On an incremental build this also fails on a stale target/classes copy left by a build " + + "from before the file was deleted -- which a war built from that directory really would ship. " + + "mvn clean test settles which one it is."); + } + + /** An SsoManager whose sso.type is the field the tests set. */ + private SsoManager newManager() { + return new SsoManager() { + @Override + protected String getSsoType() { + return currentSsoType; + } + }; + } + // Helper classes for testing private static class TestLoginCredential implements LoginCredential { private final String username; diff --git a/src/test/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticatorTest.java b/src/test/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticatorTest.java deleted file mode 100644 index 20953548b4..0000000000 --- a/src/test/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticatorTest.java +++ /dev/null @@ -1,3022 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.entraid; - -import java.io.IOException; -import java.io.OutputStream; -import java.lang.reflect.Method; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.URLDecoder; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -import org.codelibs.core.misc.Pair; -import org.codelibs.curl.Curl; -import org.codelibs.curl.CurlResponse; -import org.codelibs.fess.app.web.base.login.ActionResponseCredential; -import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser; -import org.codelibs.fess.app.web.base.login.EntraIdCredential; -import org.codelibs.fess.entity.FessUser; -import org.codelibs.fess.exception.SsoLoginException; -import org.codelibs.fess.exception.SsoStateException; -import org.codelibs.fess.helper.ActivityHelper; -import org.codelibs.fess.helper.SystemHelper; -import org.codelibs.fess.mylasta.action.FessUserBean; -import org.codelibs.fess.mylasta.direction.FessConfig; -import org.codelibs.fess.unit.LogCapturingAppender; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.dbflute.optional.OptionalThing; -import org.dbflute.utflute.mocklet.MockletHttpServletRequest; -import org.junit.jupiter.api.Test; -import org.lastaflute.web.login.credential.LoginCredential; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import com.microsoft.aad.msal4j.ConfidentialClientApplication; -import com.microsoft.aad.msal4j.IAccount; -import com.microsoft.aad.msal4j.IAuthenticationResult; -import com.microsoft.aad.msal4j.ITenantProfile; -import com.sun.net.httpserver.HttpServer; - -public class EntraIdAuthenticatorTest extends UnitFessTestCase { - - /** - * These tests reset a setting by writing {@code ""} rather than removing it, and - * {@code ComponentUtil.getSystemProperties()} is held by the container for the life of the - * surefire fork. Without its own container this class would leave entraid.state.ttl, - * entraid.default.groups, entraid.response.mode, aad.authority and aad.response.mode - * permanently present-but-empty for every test class that runs after it -- which is the exact - * state {@code test_getAuthority_fallsBackWhenTheLegacyKeyIsPresentButBlank} and - * {@code test_getResponseMode_ignoresABlankLegacyKey} exist to characterise. - * - * @return true to create the container for each test - */ - @Override - protected boolean isUseOneTimeContainer() { - return true; - } - - private void setEntraIdConfig(final String clientId, final String clientSecret, final String tenant) { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - fessConfig.setSystemProperty("entraid.client.id", clientId); - fessConfig.setSystemProperty("entraid.client.secret", clientSecret); - fessConfig.setSystemProperty("entraid.tenant", tenant); - } - - @Test - public void test_getClientApplication_isReusedSoItsTokenCacheSurvives() { - // A fresh ConfidentialClientApplication starts with an empty TokenCache, and - // acquireTokenSilently throws NO_TOKEN_IN_CACHE on a miss. Building one per call therefore - // made silent refresh impossible; the tokens acquired at login have to stay reachable. - try { - setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - assertSame(authenticator.getClientApplication(), authenticator.getClientApplication()); - } finally { - setEntraIdConfig("", "", ""); - } - } - - @Test - public void test_getClientApplication_publishesTheApplicationAndItsKeyTogether() { - // The application and the configuration it was built from used to be two separate - // volatile fields, read one after the other. A reader that landed between the two writes - // paired the old application with the new key and kept returning the stale one. One - // reference makes that unrepresentable, and the key it carries is the one the published - // application was actually built from. - try { - setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - final ConfidentialClientApplication application = authenticator.getClientApplication(); - - assertNotNull(authenticator.clientApplicationHolder); - assertSame(application, authenticator.clientApplicationHolder.getApplication()); - assertEquals(authenticator.buildClientApplicationKey(), authenticator.clientApplicationHolder.getKey()); - } finally { - setEntraIdConfig("", "", ""); - } - } - - @Test - public void test_getClientApplication_isRebuiltWhenTheConfigurationChanges() { - // The client id, secret and tenant are editable from the admin screen at runtime. - try { - setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final ConfidentialClientApplication first = authenticator.getClientApplication(); - - setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-2", "contoso.onmicrosoft.com"); - final ConfidentialClientApplication afterSecretChange = authenticator.getClientApplication(); - assertTrue("secret change must rebuild", first != afterSecretChange); - - setEntraIdConfig("22222222-2222-2222-2222-222222222222", "secret-2", "contoso.onmicrosoft.com"); - assertTrue("config change must rebuild", afterSecretChange != authenticator.getClientApplication()); - - setEntraIdConfig("22222222-2222-2222-2222-222222222222", "secret-2", "fabrikam.onmicrosoft.com"); - assertTrue("config change must rebuild", afterSecretChange != authenticator.getClientApplication()); - } finally { - setEntraIdConfig("", "", ""); - } - } - - @Test - public void test_logout_evictsTheUsersTokensFromTheSharedCache() { - // MSAL4J's TokenCache is five unbounded LinkedHashMaps with no eviction; the only way - // anything leaves is removeAccount(). Now that one application is shared for the whole - // server, never calling it would keep every user who ever logged in resident until restart. - final List removed = new ArrayList<>(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected void removeAccount(final IAccount account) { - removed.add(account); - } - - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // keep the constructor off Microsoft Graph - } - }; - ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName()); - final TestAccount account = new TestAccount(); - final EntraIdUser user = new EntraIdCredential(new TestAuthenticationResult(account)).getUser(); - - assertNull(authenticator.logout(new FessUserBean(user))); - - assertEquals(1, removed.size()); - assertSame(account, removed.get(0)); - } - - /** An authenticator that records the evictions instead of reaching MSAL4J for them. */ - private EntraIdAuthenticator newAuthenticatorRecordingEvictions(final List evicted, final int maxCachedAccounts) { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected void removeAccount(final IAccount account) { - synchronized (cachedAccounts) { - cachedAccounts.remove(account.homeAccountId()); - } - evicted.add(account); - } - }; - authenticator.setMaxCachedAccounts(maxCachedAccounts); - return authenticator; - } - - @Test - public void test_trackAccount_overwritesRatherThanGrowingForTheSameAccount() { - // MSAL4J keys its cache by home account id, so a user who logs in repeatedly replaces - // their own tokens. The bound is distinct accounts, not logins, and this is what says so. - final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(new ArrayList<>(), 10); - final TestAccount account = new TestAccount("account-a"); - - for (int i = 0; i < 100; i++) { - authenticator.trackAccount(new TestAuthenticationResult(account)); - } - - assertEquals(1, authenticator.cachedAccounts.size()); - } - - @Test - public void test_trackAccount_evictsTheAccountThatWentLongestWithoutAToken() { - // Nothing leaves MSAL4J's cache on its own -- no size bound, no expiry, and an expired - // access token is filtered on read rather than removed -- so without this a server that - // never sees a Logout keeps every account resident until it restarts. - final List evicted = new ArrayList<>(); - final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(evicted, 2); - - authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-a"))); - authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-b"))); - authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-c"))); - - assertEquals(1, evicted.size()); - assertEquals("account-a", evicted.get(0).homeAccountId()); - assertEquals(Set.of("account-b", "account-c"), authenticator.cachedAccounts.keySet()); - } - - @Test - public void test_trackAccount_keepsTheAccountThatIsStillAcquiring() { - // Access order, not insertion order. A session that has been alive for days keeps - // acquiring, and evicting it first -- which is what insertion order would do -- would - // throw away exactly the account most likely to still be in use. - final List evicted = new ArrayList<>(); - final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(evicted, 2); - - final TestAccount oldest = new TestAccount("account-a"); - authenticator.trackAccount(new TestAuthenticationResult(oldest)); - authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-b"))); - authenticator.trackAccount(new TestAuthenticationResult(oldest)); - authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-c"))); - - assertEquals(1, evicted.size()); - assertEquals("account-b", evicted.get(0).homeAccountId()); - assertEquals(Set.of("account-a", "account-c"), authenticator.cachedAccounts.keySet()); - } - - @Test - public void test_trackAccount_ignoresAnAcquisitionWithNothingToTrack() { - final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(new ArrayList<>(), 2); - - authenticator.trackAccount(null); - authenticator.trackAccount(new TestAuthenticationResult(null)); - authenticator.trackAccount(new TestAuthenticationResult(new TestAccount(""))); - - assertTrue(authenticator.cachedAccounts.isEmpty()); - } - - @Test - public void test_removeAccount_freesTheSlotSoALogoutIsNotJustAnMsalCall() { - // A user who logs out must stop counting against the bound, otherwise a server with a - // steady turnover evicts live sessions to make room for accounts that already left. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setMaxCachedAccounts(2); - final TestAccount account = new TestAccount("account-a"); - authenticator.trackAccount(new TestAuthenticationResult(account)); - assertEquals(1, authenticator.cachedAccounts.size()); - - // Entra ID is unconfigured here, so the MSAL4J call behind this fails and is swallowed. - // The slot has to be freed regardless of whether that call got anywhere. - setEntraIdConfig("", "", ""); - authenticator.removeAccount(account); - - assertTrue(authenticator.cachedAccounts.isEmpty()); - } - - @Test - public void test_logout_ignoresAUserThatIsNotAnEntraIdUser() { - // SPNEGO, SAML and LDAP users reach the same logout hook. - final List removed = new ArrayList<>(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected void removeAccount(final IAccount account) { - removed.add(account); - } - }; - - assertNull(authenticator.logout(new FessUserBean(new TestFessUser()))); - assertTrue(removed.isEmpty()); - } - - /** A FessUser that is not an EntraIdUser, standing in for the other authenticators. */ - private static class TestFessUser implements org.codelibs.fess.entity.FessUser { - private static final long serialVersionUID = 1L; - - @Override - public String getName() { - return "not-an-entraid-user"; - } - - @Override - public String[] getRoleNames() { - return new String[0]; - } - - @Override - public String[] getGroupNames() { - return new String[0]; - } - - @Override - public String[] getPermissions() { - return new String[0]; - } - } - - private static class TestAccount implements IAccount { - private static final long serialVersionUID = 1L; - private final String homeAccountId; - - TestAccount() { - this("home-account-id"); - } - - TestAccount(final String homeAccountId) { - this.homeAccountId = homeAccountId; - } - - @Override - public String homeAccountId() { - return homeAccountId; - } - - @Override - public String environment() { - return "login.microsoftonline.com"; - } - - @Override - public String username() { - return "taro@contoso.onmicrosoft.com"; - } - - @Override - public Map getTenantProfiles() { - return Collections.emptyMap(); - } - } - - private static class TestAuthenticationResult implements IAuthenticationResult { - private static final long serialVersionUID = 1L; - private final IAccount account; - private final Date expiresOn; - private final String accessToken; - private final String idToken; - - TestAuthenticationResult(final IAccount account) { - this(account, new Date(Long.MAX_VALUE)); - } - - TestAuthenticationResult(final IAccount account, final Date expiresOn) { - this(account, expiresOn, "access-token", "id-token"); - } - - TestAuthenticationResult(final IAccount account, final Date expiresOn, final String accessToken) { - this(account, expiresOn, accessToken, "id-token"); - } - - TestAuthenticationResult(final IAccount account, final String idToken) { - this(account, new Date(Long.MAX_VALUE), "access-token", idToken); - } - - TestAuthenticationResult(final IAccount account, final Date expiresOn, final String accessToken, final String idToken) { - this.account = account; - this.expiresOn = expiresOn; - this.accessToken = accessToken; - this.idToken = idToken; - } - - @Override - public String accessToken() { - return accessToken; - } - - @Override - public String idToken() { - return idToken; - } - - @Override - public IAccount account() { - return account; - } - - @Override - public ITenantProfile tenantProfile() { - return null; - } - - @Override - public String environment() { - return "login.microsoftonline.com"; - } - - @Override - public String scopes() { - return "https://graph.microsoft.com/.default"; - } - - @Override - public Date expiresOnDate() { - return expiresOn; - } - } - - @Test - public void test_getClientApplication_isBuiltOnceUnderConcurrentAccess() throws Exception { - try { - setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final int threads = 8; - final CountDownLatch start = new CountDownLatch(1); - final List seen = Collections.synchronizedList(new ArrayList<>()); - final List workers = new ArrayList<>(); - for (int i = 0; i < threads; i++) { - final Thread t = new Thread(() -> { - try { - start.await(); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - seen.add(authenticator.getClientApplication()); - }); - workers.add(t); - t.start(); - } - start.countDown(); - for (final Thread t : workers) { - t.join(10000L); - } - - assertEquals(threads, seen.size()); - // Two instances means two token caches, and a login cached in one is invisible to the - // other when its refresh comes round. - seen.forEach(app -> assertSame(seen.get(0), app)); - } finally { - setEntraIdConfig("", "", ""); - } - } - - /** Lets a test move the clock that state expiry is measured against. */ - private final AtomicLong clock = new AtomicLong(1_000_000L); - - private EntraIdAuthenticator newAuthenticatorWithControlledClock() { - ComponentUtil.register(new SystemHelper() { - @Override - public long getCurrentTimeAsLong() { - return clock.get(); - } - }, "systemHelper"); - return new EntraIdAuthenticator(); - } - - @Test - public void test_maskSecret() { - // Long values keep a short prefix so two different codes can still be told apart - // in a log, without the value itself being usable. - assertEquals("abcdefgh***", EntraIdAuthenticator.maskSecret("abcdefghijklmnopqrstuvwxyz")); - // Values shorter than the prefix are not padded out. - assertEquals("abc***", EntraIdAuthenticator.maskSecret("abc")); - assertEquals("abcdefgh***", EntraIdAuthenticator.maskSecret("abcdefgh")); - // Null and empty must not blow up: these are logged on paths where the identity - // provider may simply not have sent the field. - assertNull(EntraIdAuthenticator.maskSecret(null)); - assertEquals("", EntraIdAuthenticator.maskSecret("")); - } - - @Test - public void test_maskParams_masksCredentialsAndKeepsDiagnostics() { - final Map> params = new LinkedHashMap<>(); - params.put("code", List.of("0.AXkAauthorizationcodevalue")); - params.put("id_token", List.of("eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.payload.sig")); - params.put("state", List.of("2b1f5c3e-0000-0000-0000-000000000000")); - params.put("error", List.of("access_denied")); - params.put("error_description", List.of("AADSTS65004: User declined to consent.")); - - final Map> masked = EntraIdAuthenticator.maskParams(params); - - // Credentials are truncated. - assertEquals(List.of("0.AXkAau***"), masked.get("code")); - assertEquals(List.of("eyJ0eXAi***"), masked.get("id_token")); - // Everything needed to diagnose a failed login is kept verbatim. - assertEquals(List.of("2b1f5c3e-0000-0000-0000-000000000000"), masked.get("state")); - assertEquals(List.of("access_denied"), masked.get("error")); - assertEquals(List.of("AADSTS65004: User declined to consent."), masked.get("error_description")); - // The key set is unchanged, so the log still shows which artifacts arrived. - assertEquals(params.keySet(), masked.keySet()); - // The caller's map is not modified. - assertEquals(List.of("0.AXkAauthorizationcodevalue"), params.get("code")); - } - - @Test - public void test_maskParams_isCaseInsensitiveOnKeys() { - final Map> params = new LinkedHashMap<>(); - params.put("Code", List.of("0.AXkAauthorizationcodevalue")); - params.put("ACCESS_TOKEN", List.of("accesstokenvalue12345")); - - final Map> masked = EntraIdAuthenticator.maskParams(params); - - assertEquals(List.of("0.AXkAau***"), masked.get("Code")); - assertEquals(List.of("accessto***"), masked.get("ACCESS_TOKEN")); - } - - @Test - public void test_getAuthUrl_requestsQueryResponseMode() { - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - final String authUrl = authenticator.getAuthUrl(getMockRequest()); - - // form_post makes Entra ID reply with a cross-site POST, which does not carry a - // SameSite=Lax session cookie -- and Fess sets SameSite=Lax on JSESSIONID by default - // (tomcat.sameSiteCookies). Without the session there is no stored state, so the - // callback can never be validated. query mode replies with a top-level GET instead. - assertTrue(authUrl.contains("response_mode=query")); - assertFalse(authUrl.contains("response_mode=form_post")); - // The rest of the authorization request is unchanged. - assertTrue(authUrl.contains("response_type=code")); - assertTrue(authUrl.contains("&state=")); - assertTrue(authUrl.contains("&nonce=")); - } - - @Test - public void test_getAuthUrl_alwaysUsesTheV2Endpoint() { - // msal4j hardcodes oauth2/v2.0/token as the token endpoint of an AAD authority, so a code - // minted at the v1.0 /oauth2/authorize could never be redeemed. The setter is kept so an - // out-of-tree fess_sso+entraidAuthenticator.xml still loads, but it no longer selects a - // login that always fails. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setUseV2Endpoint(false); - - final String authUrl = authenticator.getAuthUrl(getMockRequest()); - - assertTrue(authUrl.contains("/oauth2/v2.0/authorize?")); - assertFalse(authUrl.contains("resource=https%3a%2f%2fgraph.microsoft.com")); - assertTrue(authUrl.contains("response_mode=query")); - } - - @Test - public void test_getAuthUrl_requestsTheOidcScopesUpFront() { - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - final String authUrl = authenticator.getAuthUrl(getMockRequest()); - - // msal4j already prepends these to the token request (OAuthAuthorizationGrant's - // COMMON_SCOPES), so asking for them at the authorization endpoint too keeps consent and - // the token exchange asking for the same thing. - final String scope = URLDecoder.decode(authUrl.replaceFirst("(?s).*[?&]scope=([^&]*).*", "$1"), StandardCharsets.UTF_8); - assertEquals("openid profile offline_access https://graph.microsoft.com/.default", scope); - } - - @Test - public void test_getAuthUrl_encodesTheScopeParameter() { - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - final String authUrl = authenticator.getAuthUrl(getMockRequest()); - - // A multi-valued scope is space separated, which cannot be sent raw. - assertFalse(authUrl.contains("scope=openid profile")); - assertTrue(authUrl.contains("&client_id=")); - } - - @Test - public void test_containsAuthenticationData_acceptsQueryModeCallback() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - request.setParameter("code", "0.AXkAauthorizationcodevalue"); - request.setParameter("state", "2b1f5c3e-0000-0000-0000-000000000000"); - - assertTrue(authenticator.containsAuthenticationData(request)); - } - - @Test - public void test_containsAuthenticationData_acceptsFormPostCallback() { - // An existing deployment that already set tomcat.sameSiteCookies=none keeps working. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("POST"); - request.setParameter("code", "0.AXkAauthorizationcodevalue"); - - assertTrue(authenticator.containsAuthenticationData(request)); - } - - @Test - public void test_containsAuthenticationData_acceptsErrorCallback() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - request.setParameter("error", "access_denied"); - - assertTrue(authenticator.containsAuthenticationData(request)); - } - - @Test - public void test_getLoginCredential_surfacesUnexpectedFailures() { - // A swallowed failure leaves the operator with nothing above DEBUG to work from. - // SsoAction logs SsoLoginException at WARN and shows the SSO error message, which is - // what the OpenID Connect authenticator already relies on. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected LoginCredential processAuthenticationData(final HttpServletRequest request) { - throw new IllegalStateException("graph is down"); - } - }; - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - request.setParameter("code", "0.AXkAauthorizationcodevalue"); - request.getSession(); - - try { - authenticator.getLoginCredential(); - fail("expected SsoLoginException"); - } catch (final SsoLoginException e) { - assertEquals("graph is down", e.getCause().getMessage()); - } - } - - @Test - public void test_getLoginCredential_propagatesSsoLoginExceptionUnwrapped() { - final SsoLoginException thrown = new SsoLoginException("could not validate state"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected LoginCredential processAuthenticationData(final HttpServletRequest request) { - throw thrown; - } - }; - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - request.setParameter("code", "0.AXkAauthorizationcodevalue"); - request.getSession(); - - try { - authenticator.getLoginCredential(); - fail("expected SsoLoginException"); - } catch (final SsoLoginException e) { - assertSame(thrown, e); - } - } - - @Test - public void test_getLoginCredential_doesNotRedirectACallbackThatLostItsSession() { - // Redirecting a callback that arrived without a session sends the user straight back - // here without a session again, which is the infinite loop this change is about. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - request.setParameter("code", "0.AXkAauthorizationcodevalue"); - request.setParameter("state", "2b1f5c3e-0000-0000-0000-000000000000"); - assertNull(request.getSession(false)); - - assertNull(authenticator.getLoginCredential()); - } - - @Test - public void test_getLoginCredential_restartsTheLoginWhenTheSessionMerelyExpired() { - // The browser did send a session id, so it stores and returns cookies; the container just - // no longer knows that session. 15.7 recovered by bouncing back to Entra ID, and dropping - // the user on the local login form instead leaves them stuck -- that form has no SSO link. - // This cannot loop: getAuthUrl creates a session, so the next callback either finds it or - // arrives with no session id at all, which is the branch the sibling test pins. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - request.setParameter("code", "0.AXkAauthorizationcodevalue"); - request.setParameter("state", "2b1f5c3e-0000-0000-0000-000000000000"); - request.addCookie(new Cookie("jsessionid", "AB1C2D3E4F5061728394A5B6C7D8E9F0")); - assertNull(request.getSession(false)); - assertNotNull(request.getRequestedSessionId()); - assertFalse(request.isRequestedSessionIdValid()); - - try { - setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com"); - - final LoginCredential credential = authenticator.getLoginCredential(); - - assertTrue(credential instanceof ActionResponseCredential); - } finally { - setEntraIdConfig("", "", ""); - } - } - - @Test - public void test_getLoginCredential_reportsAnUnconfiguredTenantInsteadOfRedirecting() { - // Unconfigured, Fess used to redirect to - // https://login.microsoftonline.com//oauth2/v2.0/authorize?...&client_id= and log nothing, - // so the only symptom was a Microsoft error page. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - setEntraIdConfig("", "", ""); - - try { - authenticator.getLoginCredential(); - fail("expected SsoLoginException"); - } catch (final SsoLoginException e) { - assertTrue(e.getMessage(), e.getMessage().contains("entraid.tenant")); - assertTrue(e.getMessage(), e.getMessage().contains("entraid.client.id")); - assertTrue(e.getMessage(), e.getMessage().contains("entraid.client.secret")); - } - } - - @Test - public void test_getLoginCredential_reportsThePartiallyConfiguredKeysOnly() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - - try { - setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", ""); - - authenticator.getLoginCredential(); - fail("expected SsoLoginException"); - } catch (final SsoLoginException e) { - assertTrue(e.getMessage(), e.getMessage().contains("entraid.tenant")); - assertFalse(e.getMessage(), e.getMessage().contains("entraid.client.id")); - } finally { - setEntraIdConfig("", "", ""); - } - } - - @Test - public void test_getLoginCredential_thrownEagerlyRatherThanFromTheRedirectSupplier() { - // SsoAction runs the ActionResponseCredential supplier outside the block that catches - // SsoLoginException, so a throw from inside the lambda reaches the generic error page - // instead of the SSO error message. The check therefore has to happen before the - // credential is built, not when it is executed. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - setEntraIdConfig("", "", ""); - - try { - authenticator.getLoginCredential(); - fail("expected SsoLoginException before any credential was returned"); - } catch (final SsoLoginException e) { - // expected - } - } - - @Test - public void test_getAuthUrl_issuesAnUnguessableState() { - // org.codelibs.core.net.UuidUtil is hex(localIP) + hex(identityHashCode(RANDOM)) + - // hex((int) (currentTimeMillis() >> 32)) + hex(SecureRandom.nextInt()): the first 16 hex - // characters never change within a JVM and the timestamp word moves every ~49.7 days, so - // under 32 bits actually vary per call. RFC 6749 section 10.12 wants the state - // unguessable. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Set states = new HashSet<>(); - final Set prefixes = new HashSet<>(); - for (int i = 0; i < 200; i++) { - final String state = URLDecoder.decode( - authenticator.getAuthUrl(newAuthUrlRequest()).replaceFirst("(?s).*[&?]state=([^&]*).*", "$1"), StandardCharsets.UTF_8); - states.add(state); - prefixes.add(state.replace("-", "").substring(0, 16)); - } - - assertEquals(200, states.size()); - // The whole point: a fixed leading half is what UuidUtil produced. - assertTrue("distinct prefixes: " + prefixes.size(), prefixes.size() > 190); - } - - @Test - public void test_getAuthUrl_issuesADistinctNoncePerRequest() { - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Set nonces = new HashSet<>(); - for (int i = 0; i < 50; i++) { - nonces.add(authenticator.getAuthUrl(newAuthUrlRequest()).replaceFirst("(?s).*&nonce=([^&]*).*", "$1")); - } - - assertEquals(50, nonces.size()); - } - - @Test - public void test_createGroupCache_isBounded() { - // Every sibling cache in Fess caps its size; this one only had an expiry, so a tenant with - // many groups grew it without bound until the TTL came round. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - assertTrue(authenticator.maxGroupCacheSize > 0); - authenticator.setMaxGroupCacheSize(2); - - final Cache> cache = authenticator.createGroupCache(); - for (int i = 0; i < 10; i++) { - cache.put("group-" + i, new Pair<>(new String[0], new String[0])); - } - cache.cleanUp(); - - assertTrue("size=" + cache.size(), cache.size() <= 2); - } - - @Test - public void test_containsAuthenticationData_ignoresRequestWithoutArtifacts() { - // A plain visit to /sso must still start a fresh login instead of being treated - // as a callback. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - - assertFalse(authenticator.containsAuthenticationData(request)); - } - - @Test - public void test_graphTimeouts_haveBoundedDefaults() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - // curl4j leaves both timeouts at -1 unless told otherwise, and -1 means "never give up". - // Any non-positive default would put an unbounded Graph call back on the login path. - assertTrue(authenticator.graphConnectTimeout > 0); - assertTrue(authenticator.graphReadTimeout > 0); - } - - @Test - public void test_createGraphRequest_stopsWaitingOnAnUnresponsiveEndpoint() throws Exception { - // A server that accepts the connection and then never answers. Without a read timeout - // this call never returns, and on the login path that blocks the request thread. - try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setGraphReadTimeout(300); - final String url = "http://" + server.getInetAddress().getHostAddress() + ":" + server.getLocalPort() + "/v1.0/me/memberOf"; - - final long start = System.currentTimeMillis(); - try (CurlResponse response = authenticator.createGraphRequest(Curl.get(url), "access-token").execute()) { - fail("expected the request to time out"); - } catch (final Exception expected) { - // curl4j wraps the SocketTimeoutException - } - final long elapsed = System.currentTimeMillis() - start; - // Well under the 30s default, so an ignored setter fails here instead of just being slow. - assertTrue("took " + elapsed + "ms", elapsed < 5000L); - } - } - - @Test - public void test_storeStateInSession_dropsExpiredStatesOnWrite() { - // Expired states used to be cleared only when a callback arrived. A user who keeps - // starting logins without finishing one grew the session map without bound. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final HttpSession session = getMockRequest().getSession(); - - authenticator.storeStateInSession(session, "state-1", "nonce-1"); - assertEquals(1, authenticator.getStateMap(session).size()); - - // getStateTtl() defaults to 3600 and is compared in seconds. - clock.addAndGet(3601L * 1000L); - authenticator.storeStateInSession(session, "state-2", "nonce-2"); - - final Map stateMap = authenticator.getStateMap(session); - assertEquals(1, stateMap.size()); - assertTrue(stateMap.containsKey("state-2")); - } - - @Test - public void test_storeStateInSession_capsTheNumberOfLiveStates() { - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - authenticator.setMaxStates(3); - final HttpSession session = getMockRequest().getSession(); - - for (int i = 0; i < 20; i++) { - clock.addAndGet(1000L); - authenticator.storeStateInSession(session, "state-" + i, "nonce-" + i); - } - - final Map stateMap = authenticator.getStateMap(session); - assertEquals(3, stateMap.size()); - // The most recent attempts are the ones a user can still complete. - assertTrue(stateMap.containsKey("state-19")); - assertTrue(stateMap.containsKey("state-18")); - assertTrue(stateMap.containsKey("state-17")); - } - - @Test - public void test_getStateMap_migratesALegacyHashMap() { - // A session created before this change holds a plain HashMap under the same key. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final HttpSession session = getMockRequest().getSession(); - final Map legacy = new HashMap<>(); - legacy.put("legacy-state", new EntraIdAuthenticator.StateData("legacy-nonce", clock.get())); - session.setAttribute("entraidStates", legacy); - - final Map stateMap = authenticator.getStateMap(session); - - assertTrue(stateMap instanceof ConcurrentHashMap); - assertEquals("legacy-nonce", stateMap.get("legacy-state").getNonce()); - // The migrated map is the one stored back on the session, so later writes are not lost. - assertSame(stateMap, session.getAttribute("entraidStates")); - } - - @Test - public void test_getStateMap_isCreatedOnceUnderConcurrentAccess() throws Exception { - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final HttpSession session = getMockRequest().getSession(); - final int threads = 8; - final CountDownLatch start = new CountDownLatch(1); - final List> seen = Collections.synchronizedList(new ArrayList<>()); - final List workers = new ArrayList<>(); - for (int i = 0; i < threads; i++) { - final Thread t = new Thread(() -> { - try { - start.await(); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - seen.add(authenticator.getStateMap(session)); - }); - workers.add(t); - t.start(); - } - start.countDown(); - for (final Thread t : workers) { - t.join(10000L); - } - - assertEquals(threads, seen.size()); - // Every caller must share one map, otherwise a state stored by one request is invisible - // to the request that has to validate it. - seen.forEach(m -> assertSame(seen.get(0), m)); - } - - @Test - public void test_removeStateFromSession_stillDropsExpiredStates() { - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final HttpSession session = getMockRequest().getSession(); - authenticator.storeStateInSession(session, "state-1", "nonce-1"); - - clock.addAndGet(3601L * 1000L); - - assertNull(authenticator.removeStateFromSession(session, "state-1")); - assertEquals(0, authenticator.getStateMap(session).size()); - } - - @Test - public void test_removeStateFromSession_returnsAndConsumesALiveState() { - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final HttpSession session = getMockRequest().getSession(); - authenticator.storeStateInSession(session, "state-1", "nonce-1"); - - final EntraIdAuthenticator.StateData stateData = authenticator.removeStateFromSession(session, "state-1"); - - assertNotNull(stateData); - assertEquals("nonce-1", stateData.getNonce()); - // A state is single use. - assertNull(authenticator.removeStateFromSession(session, "state-1")); - } - - /** - * An authenticator whose Graph access is replaced by a scripted one, so the caching and - * recursion around it can be exercised without a tenant. - */ - private static class ScriptedAuthenticator extends EntraIdAuthenticator { - private final Map parents = new HashMap<>(); - private final List lookups = new ArrayList<>(); - private final Set failing = new HashSet<>(); - - @Override - protected String[] getMemberGroupIds(final EntraIdUser user, final String id) throws IOException { - lookups.add(id); - if (failing.contains(id)) { - throw new IOException("simulated Graph failure for " + id); - } - return parents.getOrDefault(id, new String[0]); - } - - @Override - protected boolean processGroup(final EntraIdUser user, final List groupList, final List roleList, final String id) { - groupList.add(id); - return true; - } - } - - private ScriptedAuthenticator newScriptedAuthenticator() { - final ScriptedAuthenticator authenticator = new ScriptedAuthenticator(); - authenticator.groupCache = CacheBuilder.newBuilder().build(); - return authenticator; - } - - @Test - public void test_getParentGroup_cachesASuccessfulLookup() { - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - authenticator.parents.put("group-a", new String[] { "group-b" }); - - final Pair first = authenticator.getParentGroup(null, "group-a", 0); - final Pair second = authenticator.getParentGroup(null, "group-a", 0); - - assertEquals(1, first.getFirst().length); - assertEquals("group-b", first.getFirst()[0]); - assertEquals(1, second.getFirst().length); - // "group-a" is looked up once; "group-b" is walked once while loading it. - assertEquals(1, authenticator.lookups.stream().filter("group-a"::equals).count()); - } - - @Test - public void test_getParentGroup_doesNotServeAnEntryBuiltFromAnotherPermissionFieldSetting() { - // The cached pair holds the permission values entraid.permission.fields selected, not the - // raw Graph answer. Keyed by group id alone, a change to the setting was ignored for - // nested groups until the entry expired -- while direct memberships, which are read from - // Graph on every login, picked it up at once. Narrowing the setting is the case that - // matters: displayName is neither domain-qualified nor unique, and removing it once it has - // matched the wrong documents must not keep granting it for another ten minutes. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - authenticator.parents.put("group-a", new String[] { "group-b" }); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.permission.fields", "mail"); - authenticator.getParentGroup(null, "group-a", 0); - assertEquals(1, authenticator.lookups.stream().filter("group-a"::equals).count()); - - fessConfig.setSystemProperty("entraid.permission.fields", "mail,displayName"); - authenticator.getParentGroup(null, "group-a", 0); - - // Read again rather than served from the entry the old setting produced. - assertEquals(2, authenticator.lookups.stream().filter("group-a"::equals).count()); - // The entry the first setting produced is still addressable under its own key, so the - // two settings do not evict each other. - fessConfig.setSystemProperty("entraid.permission.fields", "mail"); - assertNotNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a"))); - } finally { - fessConfig.setSystemProperty("entraid.permission.fields", ""); - } - } - - @Test - public void test_getParentGroup_doesNotCacheAFailedLookup() { - // A throttled or briefly unreachable Graph used to leave an empty result in the cache, - // so the user silently lost their parent-group permissions for the whole cache TTL. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - authenticator.failing.add("group-a"); - - final Pair failed = authenticator.getParentGroup(null, "group-a", 0); - assertEquals(0, failed.getFirst().length); - assertNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a"))); - - authenticator.failing.remove("group-a"); - authenticator.parents.put("group-a", new String[] { "group-b" }); - - final Pair recovered = authenticator.getParentGroup(null, "group-a", 0); - assertEquals(1, recovered.getFirst().length); - assertEquals("group-b", recovered.getFirst()[0]); - } - - @Test - public void test_getParentGroup_cachesAGenuineEmptyResult() { - // A group with no parents is a real answer, not a transient failure, so it is cached. - // getMemberGroupIds maps Graph's Request_ResourceNotFound onto this same empty result. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - - final Pair result = authenticator.getParentGroup(null, "group-a", 0); - - assertEquals(0, result.getFirst().length); - assertNotNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a"))); - } - - @Test - public void test_getParentGroup_doesNotRecurseOnceTheParentWasResolved() { - // Characterisation: processGroup() unconditionally adds the id it was asked about, so the - // `!groupList.contains(value)` guard in loadParentGroup is false on every success path and - // the nested-group recursion never runs. maxGroupDepth therefore has no effect here. This - // is consistent with Graph's getMemberGroups already being transitive, but it means the - // recursion is not what resolves nested groups -- pinning it so a change is deliberate. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - authenticator.parents.put("group-a", new String[] { "group-b" }); - authenticator.parents.put("group-b", new String[] { "group-c" }); - - final Pair result = authenticator.getParentGroup(null, "group-a", 0); - - assertEquals(1, result.getFirst().length); - assertEquals("group-b", result.getFirst()[0]); - assertFalse(authenticator.lookups.contains("group-b")); - } - - @Test - public void test_getParentGroup_survivesAnUncheckedFailureFromTheLoader() { - // Guava wraps an unchecked exception from the loader in UncheckedExecutionException, - // which is not an ExecutionException, so it used to escape the catch entirely. The Graph - // JSON parser throws CurlException, a RuntimeException, on a non-JSON error body. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected String[] getMemberGroupIds(final EntraIdUser user, final String id) { - throw new IllegalStateException("non-JSON error body"); - } - }; - authenticator.groupCache = CacheBuilder.newBuilder().build(); - - final Pair result = authenticator.getParentGroup(null, "group-a", 0); - - assertEquals(0, result.getFirst().length); - assertEquals(0, result.getSecond().length); - assertNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a"))); - } - - @Test - public void test_applyGraphThrottle_honoursTheRetryAfterGraphSent() { - // curl4j does not throw on a non-2xx response -- CurlRequest hands back the error stream -- - // so a Graph 429 arrives as an ordinary parsed body and the status code is the only place - // the throttling is visible. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final CurlResponse response = new CurlResponse(); - response.setHttpStatusCode(429); - response.setHeaders(Map.of("Retry-After", List.of("120"))); - - authenticator.applyGraphThrottle(response); - - assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil); - assertTrue(authenticator.isGraphThrottled()); - - clock.addAndGet(120_000L); - assertFalse("the backoff has to lapse on its own", authenticator.isGraphThrottled()); - } - - @Test - public void test_applyGraphThrottle_alsoBacksOffOnServiceUnavailable() { - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final CurlResponse response = new CurlResponse(); - response.setHttpStatusCode(503); - - authenticator.applyGraphThrottle(response); - - // No Retry-After: the default backoff applies rather than none at all. - assertEquals(clock.get() + 60_000L, authenticator.graphThrottledUntil); - } - - @Test - public void test_applyGraphThrottle_ignoresAnOrdinaryResponse() { - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final CurlResponse ok = new CurlResponse(); - ok.setHttpStatusCode(200); - final CurlResponse forbidden = new CurlResponse(); - forbidden.setHttpStatusCode(403); - - authenticator.applyGraphThrottle(ok); - authenticator.applyGraphThrottle(forbidden); - - assertEquals(0L, authenticator.graphThrottledUntil); - assertFalse(authenticator.isGraphThrottled()); - } - - @Test - public void test_parseRetryAfterSeconds_fallsBackAndStaysBounded() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - assertEquals(30L, authenticator.parseRetryAfterSeconds(" 30 ")); - assertEquals(60L, authenticator.parseRetryAfterSeconds(null)); - assertEquals(60L, authenticator.parseRetryAfterSeconds("")); - assertEquals(60L, authenticator.parseRetryAfterSeconds("0")); - // RFC 9110 also allows an HTTP-date; Graph sends delay-seconds, so this is a fallback. - assertEquals(60L, authenticator.parseRetryAfterSeconds("Wed, 21 Oct 2026 07:28:00 GMT")); - // An unbounded value would leave nested groups unresolved for the rest of the day. - assertEquals(3600L, authenticator.parseRetryAfterSeconds("999999")); - } - - @Test - public void test_getParentGroup_skipsTheWalkWhileGraphIsThrottling() { - // 15.7 cached an empty result for the cache TTL, which #3223 removed because it silently - // took the parent group permissions away for ten minutes. The backoff replaces it: the - // walk is skipped while Graph asked us to wait, nothing is written to the cache, and it - // resumes by itself. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - ComponentUtil.register(new SystemHelper() { - @Override - public long getCurrentTimeAsLong() { - return clock.get(); - } - }, "systemHelper"); - authenticator.parents.put("group-a", new String[] { "group-b" }); - authenticator.graphThrottledUntil = clock.get() + 60_000L; - - final Pair throttled = authenticator.getParentGroup(null, "group-a", 0); - - assertEquals(0, throttled.getFirst().length); - assertTrue(authenticator.lookups.isEmpty()); - assertNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a")), "a skipped walk must not be cached"); - - clock.addAndGet(60_000L); - final Pair recovered = authenticator.getParentGroup(null, "group-a", 0); - - assertEquals(1, recovered.getFirst().length); - assertEquals("group-b", recovered.getFirst()[0]); - } - - @Test - public void test_getParentGroup_stillServesACachedAnswerWhileThrottling() { - // The backoff exists to stop Graph being called, not to throw away memberships that were - // already resolved. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - ComponentUtil.register(new SystemHelper() { - @Override - public long getCurrentTimeAsLong() { - return clock.get(); - } - }, "systemHelper"); - authenticator.parents.put("group-a", new String[] { "group-b" }); - assertEquals(1, authenticator.getParentGroup(null, "group-a", 0).getFirst().length); - - authenticator.graphThrottledUntil = clock.get() + 60_000L; - - assertEquals(1, authenticator.getParentGroup(null, "group-a", 0).getFirst().length); - } - - @Test - public void test_getStateTtl_defaultsToOneHourInSeconds() { - // removeExpiredStates compares (now - created) / 1000 against this value, so the unit is - // seconds. The javadoc used to say milliseconds. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - assertEquals(3600L, authenticator.getStateTtl()); - } - - @Test - public void test_getStateTtl_fallsBackWhenTheConfiguredValueIsNotANumber() { - // A typo in conf/system.properties used to fail the login with a NumberFormatException - // rather than a message anyone could act on. - ComponentUtil.getFessConfig().setSystemProperty("entraid.state.ttl", "one hour"); - try { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - assertEquals(3600L, authenticator.getStateTtl()); - } finally { - ComponentUtil.getFessConfig().setSystemProperty("entraid.state.ttl", ""); - } - } - - @Test - public void test_getStateTtl_fallsBackWhenTheConfiguredValueIsNotPositive() { - // Long.parseLong accepts "0" and "-1", so these used to be taken at face value. - // removeExpiredStates then dropped every state one second after it was created, and the - // user -- who spends longer than that signing in at Microsoft -- came back to - // "could not validate state" on every attempt. Nothing in the log named the setting, so - // the server looked broken rather than misconfigured. - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - for (final String value : new String[] { "0", "-1" }) { - final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class); - try { - fessConfig.setSystemProperty("entraid.state.ttl", value); - - assertEquals(3600L, new EntraIdAuthenticator().getStateTtl()); - assertTrue(logs.warnings().toString(), - logs.warnings().stream().anyMatch(m -> m.contains("entraid.state.ttl") && m.contains(value))); - } finally { - logs.detach(); - fessConfig.setSystemProperty("entraid.state.ttl", ""); - } - } - } - - @Test - public void test_getPermissionFieldValue_ignoresAFieldThatIsNotAString() { - // entraid.permission.fields is a list of Graph field names, and the documentation does not - // say the field has to hold a string. securityEnabled and groupTypes are the two most - // plausible wrong answers -- they are on every group object -- and both used to throw - // ClassCastException out of the middle of processDirectMemberOf, where the catch turned a - // single mistyped field name into "no group permissions at all" for the whole tenant. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Map group = new HashMap<>(); - group.put("displayName", "Engineering"); - group.put("securityEnabled", Boolean.TRUE); - group.put("groupTypes", Arrays.asList("Unified")); - - final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class); - try { - assertEquals("Engineering", authenticator.getPermissionFieldValue(group, "displayName")); - assertNull(authenticator.getPermissionFieldValue(group, "securityEnabled")); - assertNull(authenticator.getPermissionFieldValue(group, "groupTypes")); - // Absent is not a misconfiguration worth a warning: a group without a mail address is - // the normal case for a security group. - assertNull(authenticator.getPermissionFieldValue(group, "mail")); - - assertEquals(logs.warnings().toString(), 2L, - logs.warnings().stream().filter(m -> m.contains("entraid.permission.fields")).count()); - assertTrue(logs.warnings().toString(), logs.warnings().stream().anyMatch(m -> m.contains("securityEnabled"))); - assertTrue(logs.warnings().toString(), logs.warnings().stream().anyMatch(m -> m.contains("groupTypes"))); - } finally { - logs.detach(); - } - } - - @Test - public void test_getPermissionFieldValue_warnsOncePerFieldName() { - // The read runs once per group per login, so warning every time would bury the rest of the - // log under a message that says the same thing. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Map group = new HashMap<>(); - group.put("securityEnabled", Boolean.TRUE); - - final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class); - try { - for (int i = 0; i < 5; i++) { - assertNull(authenticator.getPermissionFieldValue(group, "securityEnabled")); - } - assertEquals(logs.warnings().toString(), 1L, logs.warnings().stream().filter(m -> m.contains("securityEnabled")).count()); - } finally { - logs.detach(); - } - } - - @Test - public void test_getStateTtl_keepsAPositiveValue() { - // The guard must not round a deliberately short expiry up to the default. - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.state.ttl", "1"); - assertEquals(1L, new EntraIdAuthenticator().getStateTtl()); - } finally { - fessConfig.setSystemProperty("entraid.state.ttl", ""); - } - } - - @Test - public void test_addGroupOrRoleName() { - EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - List list = new ArrayList<>(); - - list.clear(); - authenticator.addGroupOrRoleName(list, "test", true); - assertEquals(1, list.size()); - assertEquals("test", list.get(0)); - - list.clear(); - authenticator.addGroupOrRoleName(list, "test", false); - assertEquals(1, list.size()); - assertEquals("test", list.get(0)); - - list.clear(); - authenticator.addGroupOrRoleName(list, "test@codelibs.org", true); - assertEquals(2, list.size()); - assertEquals("test@codelibs.org", list.get(0)); - assertEquals("test", list.get(1)); - - list.clear(); - authenticator.addGroupOrRoleName(list, "test@codelibs.org", false); - assertEquals(1, list.size()); - assertEquals("test@codelibs.org", list.get(0)); - - list.clear(); - authenticator.addGroupOrRoleName(list, "test@codelibs.org@hoge.com", true); - assertEquals(2, list.size()); - assertEquals("test@codelibs.org@hoge.com", list.get(0)); - assertEquals("test", list.get(1)); - - } - - @Test - public void test_setGroupCacheExpiry_isHonouredByTheCacheItBuilds() { - // The setter is what a fess_sso+entraidAuthenticator.xml writes, so it has to reach the - // builder rather than just a field nothing reads. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setGroupCacheExpiry(0L); - - final Cache> expiringCache = authenticator.createGroupCache(); - expiringCache.put("group-a", new Pair<>(new String[0], new String[0])); - expiringCache.cleanUp(); - assertNull(expiringCache.getIfPresent("group-a")); - - authenticator.setGroupCacheExpiry(600L); - final Cache> livingCache = authenticator.createGroupCache(); - livingCache.put("group-a", new Pair<>(new String[0], new String[0])); - livingCache.cleanUp(); - assertNotNull(livingCache.getIfPresent("group-a")); - } - - @Test - public void test_getParentGroup_withDepthLimit() { - EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setMaxGroupDepth(2); - - // Test that depth limit returns empty arrays when depth is exceeded - // With depth limit set to 2, depth 10 should return empty arrays - Pair result = authenticator.getParentGroup(null, "test-id", 10); - assertNotNull(result); - assertEquals(0, result.getFirst().length); - assertEquals(0, result.getSecond().length); - } - - @Test - public void test_getParentGroup_exactlyAtDepthLimit() { - EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setMaxGroupDepth(5); - - // Test with depth exactly at the limit - should return empty arrays - Pair result = authenticator.getParentGroup(null, "test-id", 5); - assertNotNull(result); - assertEquals(0, result.getFirst().length); - assertEquals(0, result.getSecond().length); - } - - @Test - public void test_getParentGroup_oneBeforeDepthLimit() { - // The sibling tests above pin the depth check returning early. This one has to prove the - // opposite -- that one below the limit the lookup really is attempted -- so it needs a - // cache and a scripted Graph. Without them getParentGroup throws NullPointerException on - // the null groupCache, which the version of this test inherited from 15.7 swallowed along - // with everything else. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - authenticator.setMaxGroupDepth(5); - authenticator.parents.put("test-id", new String[] { "parent-id" }); - - final Pair result = authenticator.getParentGroup(null, "test-id", 4); - - assertEquals(1, result.getFirst().length); - assertEquals("parent-id", result.getFirst()[0]); - assertTrue(authenticator.lookups.contains("test-id")); - } - - @Test - public void test_processParentGroup_callsOverloadWithDepth() { - // maxGroupDepth is 1, so the walk happens only if the overload starts at depth 0. Passing - // anything else through would make this silently do nothing. - final ScriptedAuthenticator authenticator = newScriptedAuthenticator(); - authenticator.setMaxGroupDepth(1); - authenticator.parents.put("test-id", new String[] { "parent-id" }); - final List groupList = new ArrayList<>(); - final List roleList = new ArrayList<>(); - - authenticator.processParentGroup(null, groupList, roleList, "test-id"); - - assertTrue(authenticator.lookups.contains("test-id")); - assertEquals(1, groupList.size()); - assertEquals("parent-id", groupList.get(0)); - assertEquals(0, roleList.size()); - } - - @Test - public void test_processParentGroup_respectsDepthLimit() { - EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setMaxGroupDepth(2); - - List groupList = new ArrayList<>(); - List roleList = new ArrayList<>(); - - // Test with depth exceeding limit - should return immediately - authenticator.processParentGroup(null, groupList, roleList, "test-id", 5); - - // Lists should remain empty as depth limit prevents processing - assertEquals(0, groupList.size()); - assertEquals(0, roleList.size()); - } - - @SuppressWarnings("deprecation") - @Test - public void test_setUseV2Endpoint_isKeptAsANoOpForOutOfTreeDiFiles() { - // Removing the public setter would break a fess_sso+entraidAuthenticator.xml that still - // sets the property, so it stays -- but it no longer changes anything. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - authenticator.setUseV2Endpoint(true); - final String afterTrue = authenticator.getAuthUrl(getMockRequest()); - authenticator.setUseV2Endpoint(false); - final String afterFalse = authenticator.getAuthUrl(getMockRequest()); - - assertTrue(afterTrue.contains("/oauth2/v2.0/authorize?")); - assertTrue(afterFalse.contains("/oauth2/v2.0/authorize?")); - } - - @Test - public void test_defaultMaxGroupDepth() { - EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - - // Test that default max depth (10) prevents deep recursion - // Depth 100 should exceed default and return empty - Pair result = authenticator.getParentGroup(null, "test-id", 100); - assertNotNull(result); - assertEquals(0, result.getFirst().length); - assertEquals(0, result.getSecond().length); - } - - // ========== Tests for lazy loading implementation ========== - - /** - * Test that processDirectMemberOf method exists with correct signature. - */ - @Test - public void test_processDirectMemberOf_methodExists() throws Exception { - Method method = EntraIdAuthenticator.class.getDeclaredMethod("processDirectMemberOf", EntraIdUser.class, List.class, List.class, - List.class, String.class); - assertNotNull(method, "processDirectMemberOf method should exist"); - } - - /** - * Test that updateMemberOf still exists and is public. - */ - @Test - public void test_updateMemberOf_methodExists() throws Exception { - Method method = EntraIdAuthenticator.class.getMethod("updateMemberOf", EntraIdUser.class); - assertNotNull(method, "updateMemberOf method should exist"); - assertTrue("updateMemberOf should be public", java.lang.reflect.Modifier.isPublic(method.getModifiers())); - } - - /** - * Test processDirectMemberOf collects group IDs for parent lookup. - */ - @Test - public void test_processDirectMemberOf_collectsGroupIds() throws Exception { - // The parsing loop had no coverage at all: the version of this test inherited from 15.7 - // pointed at an unreachable URL and then asserted that three lists it had just created - // were not null. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final EntraIdUser user = newUserWithoutGraph(); - final List groupList = new ArrayList<>(); - final List roleList = new ArrayList<>(); - final List groupIdsForParentLookup = new ArrayList<>(); - - try (GraphStub graph = new GraphStub(200, Map.of(), - "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\",\"mail\":\"sales@contoso.com\"}," - + "{\"@odata.type\":\"#microsoft.graph.directoryRole\",\"id\":\"role-1\"}]}")) { - assertTrue(authenticator.processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, graph.url())); - } - - // entraid.permission.fields defaults to "mail" and entraid.use.ds to true, so the mail - // address contributes both itself and its local part. - assertEquals(List.of("group-1", "sales@contoso.com", "sales"), groupList); - assertEquals(List.of("role-1"), roleList); - // Only groups are walked for parents; a directory role has none. - assertEquals(List.of("group-1"), groupIdsForParentLookup); - } - - /** - * Test that default groups and roles are read from configuration, new key first. - */ - @Test - public void test_defaultGroupsAndRoles_readTheLegacyAzureAdKeys() { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - assertEquals(Collections.emptyList(), authenticator.getDefaultGroupList()); - assertEquals(Collections.emptyList(), authenticator.getDefaultRoleList()); - - // Blank entries are dropped and the rest trimmed, so a hand-edited list still works. - fessConfig.setSystemProperty("aad.default.groups", " everyone , , guests "); - fessConfig.setSystemProperty("aad.default.roles", " guest "); - assertEquals(List.of("everyone", "guests"), authenticator.getDefaultGroupList()); - assertEquals(List.of("guest"), authenticator.getDefaultRoleList()); - - // The renamed key wins over the legacy one. - fessConfig.setSystemProperty("entraid.default.groups", "staff"); - fessConfig.setSystemProperty("entraid.default.roles", "member"); - assertEquals(List.of("staff"), authenticator.getDefaultGroupList()); - assertEquals(List.of("member"), authenticator.getDefaultRoleList()); - } finally { - fessConfig.setSystemProperty("aad.default.groups", ""); - fessConfig.setSystemProperty("aad.default.roles", ""); - fessConfig.setSystemProperty("entraid.default.groups", ""); - fessConfig.setSystemProperty("entraid.default.roles", ""); - } - } - - /** - * Test that the client application settings fall back to the legacy Azure AD keys. - */ - @Test - public void test_clientConfiguration_fallsBackToTheLegacyAzureAdKeys() { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - fessConfig.setSystemProperty("aad.client.id", "legacy-client-id"); - fessConfig.setSystemProperty("aad.client.secret", "legacy-secret"); - fessConfig.setSystemProperty("aad.tenant", "legacy-tenant"); - fessConfig.setSystemProperty("aad.state.ttl", "120"); - fessConfig.setSystemProperty("aad.authority", "https://login.microsoftonline.us/"); - - assertEquals("legacy-client-id", authenticator.getClientId()); - assertEquals("legacy-secret", authenticator.getClientSecret()); - assertEquals("legacy-tenant", authenticator.getTenant()); - assertEquals(120L, authenticator.getStateTtl()); - assertEquals("https://login.microsoftonline.us/", authenticator.getAuthority()); - } finally { - fessConfig.setSystemProperty("aad.client.id", ""); - fessConfig.setSystemProperty("aad.client.secret", ""); - fessConfig.setSystemProperty("aad.tenant", ""); - fessConfig.setSystemProperty("aad.state.ttl", ""); - fessConfig.setSystemProperty("aad.authority", ""); - } - } - - /** - * Test that processParentGroup handles null user gracefully when depth limit is reached. - */ - @Test - public void test_processParentGroup_nullUser_depthExceeded() { - EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setMaxGroupDepth(5); - - List groupList = new ArrayList<>(); - List roleList = new ArrayList<>(); - - // With depth >= maxGroupDepth, should return immediately without error - authenticator.processParentGroup(null, groupList, roleList, "test-id", 10); - - assertEquals("groupList should remain empty", 0, groupList.size()); - assertEquals("roleList should remain empty", 0, roleList.size()); - } - - /** - * Test addGroupOrRoleName with null value handling. - */ - @Test - public void test_addGroupOrRoleName_withEmptyValue() { - EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - List list = new ArrayList<>(); - - // Empty string should still be added - authenticator.addGroupOrRoleName(list, "", true); - assertEquals(1, list.size()); - assertEquals("", list.get(0)); - } - - // =================================================================================== - // Regressions guarded from 15.7 - // ============================== - - @Test - public void test_updateMemberOf_keepsTheResolvedGroupsWhenGraphReportsAnError() { - // godHandPrologue refreshes the user on every action request, so updateMemberOf can run - // long after login. A throttled or newly unauthorised Graph used to leave the user with - // the configured defaults alone, silently taking away every permission they logged in - // with, until some later call happened to succeed. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - return false; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - user.setGroups(new String[] { "group-a", "group-b" }); - user.setRoles(new String[] { "role-a" }); - // What makes this a re-resolution rather than a first one. Not inferable from the - // memberships any more: the constructor seeds the configured defaults. - user.markResolutionCompleted(); - - authenticator.updateMemberOf(user); - - assertEquals(2, user.getGroupNames().length); - assertEquals("group-a", user.getGroupNames()[0]); - assertEquals(1, user.getRoleNames().length); - } - - @Test - public void test_updateMemberOf_degradesToTheDefaultsWhenTheFirstLookupFails() { - // The default, and what 15.7 did. Failing the login instead turns a Graph 429/503, a - // transport failure, the 15.8 graphConnectTimeout/graphReadTimeout expiring, or a - // GroupMember.Read.All permission that was never granted, into a refusal for every user - // in the tenant for as long as the condition lasts. - final EntraIdAuthenticator authenticator = newAuthenticatorWhoseLookupFails(); - final EntraIdUser user = newUserWithoutGraph(); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - fessConfig.setSystemProperty("entraid.default.roles", "guest"); - - authenticator.updateMemberOf(user); - - assertEquals(1, user.getGroupNames().length); - assertEquals("everyone", user.getGroupNames()[0]); - assertEquals(1, user.getRoleNames().length); - assertEquals("guest", user.getRoleNames()[0]); - // Degrading silently would leave the operator with a tenant of under-permissioned - // sessions and nothing in the log to explain them. - assertTrue(logs.warnings().toString(), - logs.warnings().stream().anyMatch(m -> m.contains(user.getName()) && m.contains("configured defaults"))); - } finally { - logs.detach(); - fessConfig.setSystemProperty("entraid.default.groups", ""); - fessConfig.setSystemProperty("entraid.default.roles", ""); - } - } - - @Test - public void test_updateMemberOf_keepsWhatWasCollectedBeforeTheFailure() { - // processDirectMemberOf pages through /me/memberOf and adds as it goes, so a failure on a - // later page leaves the earlier ones in the list. The result is always a superset of the - // configured defaults, which are seeded before the lookup runs. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("group-from-the-first-page"); - return false; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - - authenticator.updateMemberOf(user); - - assertEquals(List.of("everyone", "group-from-the-first-page"), List.of(user.getGroupNames())); - } finally { - fessConfig.setSystemProperty("entraid.default.groups", ""); - } - } - - /** An authenticator whose direct membership lookup always reports a failure. */ - private EntraIdAuthenticator newAuthenticatorWhoseLookupFails() { - return new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - return false; - } - }; - } - - @Test - public void test_updateMemberOf_appliesTheDefaultsWhenTheLookupFindsNoMemberships() { - // A user who genuinely belongs to nothing is a successful answer, not a failure, and the - // configured defaults are exactly what they are meant to get. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - return true; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - fessConfig.setSystemProperty("entraid.default.roles", "guest"); - - authenticator.updateMemberOf(user); - - assertEquals(1, user.getGroupNames().length); - assertEquals("everyone", user.getGroupNames()[0]); - assertEquals(1, user.getRoleNames().length); - assertEquals("guest", user.getRoleNames()[0]); - } finally { - fessConfig.setSystemProperty("entraid.default.groups", ""); - fessConfig.setSystemProperty("entraid.default.roles", ""); - } - } - - @Test - public void test_processDirectMemberOf_reportsATransportFailureInsteadOfLettingItEscape() { - // curl4j reports a transport failure as CurlException, which is unchecked, so - // catch (IOException) alone let it escape as an unhandled RuntimeException -- out of the - // EntraIdUser constructor and past every caller. The same one-line widening is applied to - // processGroup, whose Graph URL is not injectable and so is covered by inspection only. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.setGraphConnectTimeout(500); - authenticator.setGraphReadTimeout(500); - final EntraIdUser user = newUserWithoutGraph(); - final int deadPort; - try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { - deadPort = socket.getLocalPort(); - } catch (final IOException e) { - throw new IllegalStateException(e); - } - - final boolean resolved = authenticator.processDirectMemberOf(user, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), - "http://127.0.0.1:" + deadPort + "/v1.0/me/memberOf"); - - assertFalse(resolved); - } - - @Test - public void test_processDirectMemberOf_reportsAHostileBodyInsteadOfLettingItEscape() throws Exception { - // "value" is cast to List>, so a body whose value is an object throws - // ClassCastException. catch (IOException | CurlException) did not cover it, and it escaped - // updateMemberOf and the EntraIdUser constructor to the generic error page instead of - // degrading to the configured defaults. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final EntraIdUser user = newUserWithoutGraph(); - - try (GraphStub graph = new GraphStub(200, Map.of(), "{\"value\":{\"id\":\"group-1\"}}")) { - assertFalse(authenticator.processDirectMemberOf(user, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), graph.url())); - } - } - - @Test - public void test_processDirectMemberOf_recordsTheBackoffAThrottledGraphAskedFor() throws Exception { - // applyGraphThrottle had exactly one call site, inside getMemberGroupIds -- the - // asynchronous parent group walk. A 429 answered to the synchronous login lookup was - // therefore neither recorded nor honoured, and the walk went on issuing one request, and - // one stack trace, per group against a Graph that had already asked us to wait. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - - try (GraphStub graph = new GraphStub(429, Map.of("Retry-After", "120"), "{\"error\":{\"code\":\"TooManyRequests\"}}")) { - assertFalse(authenticator.processDirectMemberOf(user, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), graph.url())); - } - - assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil); - assertTrue(authenticator.isGraphThrottled()); - } - - @Test - public void test_processGroup_recordsTheBackoffAThrottledGraphAskedFor() throws Exception { - // processGroup was the one Microsoft Graph call in this class that never recorded the - // backoff, and it is the call most likely to meet a 429 first: the parent group walk - // issues one per member id, against one getMemberGroupIds per group. The gap did not - // close by itself either -- a 429 whose body is JSON parses, so groupList.add(id) runs - // and the `!groupList.contains(value)` guard in loadParentGroup then skips the recursion, - // which is the only other thing on that path that would have reached Graph. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - final List groupList = new ArrayList<>(); - - try (GraphStub graph = new GraphStub(429, Map.of("Retry-After", "120"), "{\"error\":{\"code\":\"TooManyRequests\"}}")) { - authenticator.processGroup(user, groupList, new ArrayList<>(), "group-a", graph.url()); - } - - assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil); - assertTrue(authenticator.isGraphThrottled()); - // The membership itself is unaffected: the id came from getMemberGroups, which is - // authoritative, and only the permission fields are missing from this degraded answer. - assertEquals(List.of("group-a"), groupList); - } - - @Test - public void test_processGroup_leavesTheBackoffAloneOnAnOrdinaryAnswer() throws Exception { - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - final List groupList = new ArrayList<>(); - - try (GraphStub graph = new GraphStub(200, Map.of(), "{\"id\":\"group-a\",\"mail\":\"group-a@example.com\"}")) { - authenticator.processGroup(user, groupList, new ArrayList<>(), "group-a", graph.url()); - } - - assertEquals(0L, authenticator.graphThrottledUntil); - assertFalse(authenticator.isGraphThrottled()); - // Not asserting the permission fields here: entraid.permission.fields is a system - // property, and those live for the whole JVM, so a sibling test could decide it. - assertEquals("group-a", groupList.get(0)); - } - - @Test - public void test_processDirectMemberOf_stillAsksWhileGraphIsThrottling() throws Exception { - // The backoff keeps the asynchronous parent group walk off a throttled Graph. A login has - // no such luxury: skipping the lookup would hand out the configured defaults alone for the - // whole backoff, so it has to try -- the tenant may well have recovered already. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - authenticator.graphThrottledUntil = clock.get() + 60_000L; - assertTrue(authenticator.isGraphThrottled()); - final List groupList = new ArrayList<>(); - - try (GraphStub graph = - new GraphStub(200, Map.of(), "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\"}]}")) { - assertTrue(authenticator.processDirectMemberOf(user, groupList, new ArrayList<>(), new ArrayList<>(), graph.url())); - } - - assertEquals(List.of("group-1"), groupList); - } - - @Test - public void test_processDirectMemberOf_followsTheNextLinkAcrossPages() throws Exception { - // A tenant with more direct memberships than Graph returns in one page answers with - // @odata.nextLink, and the recursion that follows it had never been executed: the stub - // served one fixed answer, so every test stopped after the first page. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - final List groupList = new ArrayList<>(); - final List groupIdsForParentLookup = new ArrayList<>(); - - try (GraphStub graph = new GraphStub(List.of( - new StubResponse(200, Map.of(), - "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\"}],\"@odata.nextLink\":\"${url}\"}"), - new StubResponse(200, Map.of(), "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-2\"}]}")))) { - assertTrue(authenticator.processDirectMemberOf(user, groupList, new ArrayList<>(), groupIdsForParentLookup, graph.url())); - assertEquals(2, graph.requestCount()); - } - - assertEquals(List.of("group-1", "group-2"), groupList); - // Both pages feed the parent group walk, not just the one the first request answered with. - assertEquals(List.of("group-1", "group-2"), groupIdsForParentLookup); - } - - @Test - public void test_processDirectMemberOf_reportsAFailureOnALaterPage() throws Exception { - // Whatever the earlier pages collected stays in the lists. updateMemberOf is what decides - // between keeping it, writing it with the configured defaults and refusing the login, so - // this must not be resolved here by returning true for a partial answer. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - final List groupList = new ArrayList<>(); - - try (GraphStub graph = new GraphStub(List.of( - new StubResponse(200, Map.of(), - "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\"}],\"@odata.nextLink\":\"${url}\"}"), - new StubResponse(500, Map.of(), "{\"error\":{\"code\":\"generalException\"}}")))) { - assertFalse(authenticator.processDirectMemberOf(user, groupList, new ArrayList<>(), new ArrayList<>(), graph.url())); - assertEquals(2, graph.requestCount()); - } - - assertEquals(List.of("group-1"), groupList); - } - - @Test - public void test_getMemberGroupIds_returnsTheParentIdsGraphAnswered() throws Exception { - // toMemberGroupIds is covered directly, but the request around it -- the POST, the - // securityEnabledOnly body and the applyGraphThrottle call ahead of the parser -- was - // never executed, because this was the one Graph call in the class with no URL seam. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - - try (GraphStub graph = new GraphStub(200, Map.of(), "{\"value\":[\"parent-a\",\"parent-b\"]}")) { - assertEquals(List.of("parent-a", "parent-b"), List.of(authenticator.getMemberGroupIds(user, "group-a", graph.url()))); - } - - assertEquals(0L, authenticator.graphThrottledUntil); - } - - @Test - public void test_getMemberGroupIds_recordsTheBackoffBeforeReadingTheBody() throws Exception { - // applyGraphThrottle runs ahead of the parser on purpose: a throttled reply is not - // required to be JSON, and the parser throws on one that is not. Recording the backoff - // afterwards would lose it for exactly the responses it exists to handle, so the body - // here is deliberately not JSON. - final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock(); - final EntraIdUser user = newUserWithoutGraph(); - - try (GraphStub graph = new GraphStub(429, Map.of("Retry-After", "120", "Content-Type", "text/plain"), "Too Many Requests")) { - try { - authenticator.getMemberGroupIds(user, "group-a", graph.url()); - fail("an unreadable throttled reply must not be mistaken for an answer"); - } catch (final IOException | RuntimeException e) { - // Expected: getParentGroup turns this into an uncached empty result. - } - } - - assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil); - assertTrue(authenticator.isGraphThrottled()); - } - - @Test - public void test_removeAccount_doesNotLetALogoutFailBecauseTheCacheCouldNotBePruned() { - // getClientApplication throws when Entra ID is not configured -- which is the state a - // server is left in after the settings are cleared while a session is still live -- and - // LogoutAction has nothing to catch it with. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - setEntraIdConfig("", "", ""); - - authenticator.removeAccount(new TestAccount()); - } - - /** - * One scripted answer from {@link GraphStub}. {@code Content-Type: application/json} is sent - * unless {@code headers} overrides it, because that is what Microsoft Graph answers with and - * what the response parser expects. - * - * @param statusCode The HTTP status to answer with. - * @param headers The headers to add, which may override the default {@code Content-Type}. - * @param body The body to answer with. See {@link GraphStub} for the {@code ${url}} token. - */ - private record StubResponse(int statusCode, Map headers, String body) { - } - - /** - * A local stand-in for the Microsoft Graph endpoint. curl4j does not throw on a non-2xx - * response, so the status code and the headers are only observable through a real request. - * - *

The scripted answers are served one per request, in order, and the last one is repeated - * once the script runs out. {@code ${url}} in a body is replaced by the stub's own URL, which - * is what lets a paged answer point its {@code @odata.nextLink} back at the stub: the URL is - * only known once the server has bound a port, so a test cannot write it into the body itself. - * - *

The single context is registered under the {@code /me/memberOf} path for every method, - * which is why the methods driven through it all take their URL as a parameter. - */ - private static final class GraphStub implements AutoCloseable { - private final HttpServer server; - private final String url; - private final List responses; - private final AtomicInteger requestCount = new AtomicInteger(); - - GraphStub(final int statusCode, final Map headers, final String body) throws IOException { - this(List.of(new StubResponse(statusCode, headers, body))); - } - - GraphStub(final List responses) throws IOException { - this.responses = responses; - server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); - // Resolved before the handler is registered rather than after: the body substitution - // below reads it, and a blank final read from a lambda does not compile. - url = "http://" + server.getAddress().getAddress().getHostAddress() + ":" + server.getAddress().getPort() + "/v1.0/me/memberOf"; - server.createContext("/v1.0/me/memberOf", exchange -> { - final StubResponse response = responses.get(Math.min(requestCount.getAndIncrement(), responses.size() - 1)); - final byte[] bytes = response.body().replace("${url}", url).getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "application/json"); - response.headers().forEach((name, value) -> exchange.getResponseHeaders().set(name, value)); - exchange.sendResponseHeaders(response.statusCode(), bytes.length); - try (OutputStream out = exchange.getResponseBody()) { - out.write(bytes); - } - }); - server.start(); - } - - String url() { - return url; - } - - int requestCount() { - return requestCount.get(); - } - - @Override - public void close() { - server.stop(0); - } - } - - @Test - public void test_updateMemberOf_replacesTheGroupsWhenGraphAnswers() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("group-c"); - return true; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - user.setGroups(new String[] { "group-a" }); - - authenticator.updateMemberOf(user); - - assertEquals(1, user.getGroupNames().length); - assertEquals("group-c", user.getGroupNames()[0]); - } - - /** - * Counts the notifications the {@link ActivityHelper} stub below receives. A no-op stub - * registered only to avoid a NullPointerException leaves the notification untested -- deleting - * the {@code permissionChanged} call from {@code updateMemberOf} kept the whole suite green -- - * so {@code test_updateMemberOf_notifiesTheActivityLogOncePerCompletedResolution} asserts on - * this instead. - */ - private final AtomicInteger permissionChangedCount = new AtomicInteger(); - - /** - * Builds a user without letting its constructor reach Microsoft Graph. The tests below drive - * {@code updateMemberOf} directly, so the registered component only has to stay quiet. - */ - private EntraIdUser newUserWithoutGraph() { - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // keep the constructor off Microsoft Graph - } - }, EntraIdAuthenticator.class.getCanonicalName()); - // updateMemberOf calls ComponentUtil.getActivityHelper().permissionChanged(...) itself - // once it lands, and test_app.xml does not register one. Most of the tests below only care - // about the EntraIdUser's own state, but the notification is the operator's record that a - // user's permissions changed, so it is counted rather than swallowed. - ComponentUtil.register(new ActivityHelper() { - @Override - public void permissionChanged(final OptionalThing user) { - permissionChangedCount.incrementAndGet(); - } - }, "activityHelper"); - return new EntraIdCredential(new TestAuthenticationResult(new TestAccount())).getUser(); - } - - @Test - public void test_toMemberGroupIds_treatsADeniedPermissionAsAnAnswerSoItCanBeCached() throws Exception { - // A Graph permission that was never granted will not appear within the cache TTL. - // Throwing left nothing cached, so every login re-issued one failing request, and one - // stack trace, per direct group. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Map contentMap = new HashMap<>(); - contentMap.put("error", Map.of("code", "Authorization_RequestDenied", "message", "Insufficient privileges")); - - assertEquals(0, authenticator.toMemberGroupIds(contentMap, "group-a").length); - } - - @Test - public void test_toMemberGroupIds_treatsAMissingGroupAsAnAnswer() throws Exception { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Map contentMap = new HashMap<>(); - contentMap.put("error", Map.of("code", "Request_ResourceNotFound", "message", "not found")); - - assertEquals(0, authenticator.toMemberGroupIds(contentMap, "group-a").length); - } - - @Test - public void test_toMemberGroupIds_throwsOnATransientFailure() { - // Throttling must stay uncached so the parents are resolved on the next attempt. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Map contentMap = new HashMap<>(); - contentMap.put("error", Map.of("code", "TooManyRequests", "message", "throttled")); - - try { - authenticator.toMemberGroupIds(contentMap, "group-a"); - fail("a throttled response must not be mistaken for an answer"); - } catch (final IOException e) { - assertTrue(e.getMessage().contains("group-a")); - } - } - - @Test - public void test_toMemberGroupIds_throwsWhenTheErrorIsNotAnObject() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Map contentMap = new HashMap<>(); - contentMap.put("error", "invalid_grant"); - - try { - authenticator.toMemberGroupIds(contentMap, "group-a"); - fail("an unparsable error must not be mistaken for an answer"); - } catch (final IOException e) { - assertTrue(e.getMessage().contains("group-a")); - } - } - - @Test - public void test_toMemberGroupIds_returnsTheValues() throws Exception { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final Map contentMap = new HashMap<>(); - contentMap.put("value", List.of("parent-a", "parent-b")); - - assertEquals(2, authenticator.toMemberGroupIds(contentMap, "group-a").length); - } - - @Test - public void test_refresh_doesNotTouchGraphWhileTheTokenIsStillFresh() { - // FessBaseAction.godHandPrologue calls refresh() on every action request. Once the MSAL4J - // application became shared, acquireTokenSilently started succeeding, and every success - // ran updateMemberOf -- a synchronous Microsoft Graph call on the request thread. - final AtomicBoolean touched = new AtomicBoolean(false); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - touched.set(true); - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - touched.set(true); - return null; - } - }; - ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName()); - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final EntraIdUser user = - new EntraIdCredential(new TestAuthenticationResult(new TestAccount(), new Date(now + 60 * 60 * 1000L))).getUser(); - touched.set(false); - - assertTrue(user.refresh()); - assertFalse(touched.get()); - } - - @Test - public void test_refresh_acquiresSilentlyOnceTheTokenIsCloseToExpiring() { - final AtomicBoolean refreshed = new AtomicBoolean(false); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // keep the constructor and the refresh off Microsoft Graph - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - refreshed.set(true); - return null; - } - }; - ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName()); - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final EntraIdUser user = - new EntraIdCredential(new TestAuthenticationResult(new TestAccount(), new Date(now + 30 * 1000L))).getUser(); - - assertTrue(user.refresh()); - assertTrue(refreshed.get()); - } - - @Test - public void test_refresh_reportsAnExpiredToken() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - // keep the constructor off Microsoft Graph - } - }; - ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName()); - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final EntraIdUser user = new EntraIdCredential(new TestAuthenticationResult(new TestAccount(), new Date(now - 1000L))).getUser(); - - assertFalse(user.refresh()); - } - - @Test - public void test_getAuthUrl_usesTheConfiguredResponseMode() { - // 15.7 hard-coded form_post and 15.8 hard-codes query. A deployment that sets - // tomcat.sameSiteCookies=none may prefer form_post to keep the authorization code out of - // the callback URL, so the mode has to be selectable rather than compiled in. - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final HttpServletRequest request = newAuthUrlRequest(); - try { - assertTrue(authenticator.getAuthUrl(request).contains("&response_mode=query&")); - - fessConfig.setSystemProperty("entraid.response.mode", "form_post"); - assertTrue(authenticator.getAuthUrl(request).contains("&response_mode=form_post&")); - } finally { - fessConfig.setSystemProperty("entraid.response.mode", ""); - } - } - - @Test - public void test_getResponseMode_fallsBackWhenTheConfiguredValueIsNotAResponseMode() { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - fessConfig.setSystemProperty("entraid.response.mode", "fragment"); - assertEquals("query", authenticator.getResponseMode()); - } finally { - fessConfig.setSystemProperty("entraid.response.mode", ""); - } - } - - @Test - public void test_getResponseMode_readsTheLegacyAzureAdKey() { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - fessConfig.setSystemProperty("aad.response.mode", " form_post "); - assertEquals("form_post", authenticator.getResponseMode()); - } finally { - fessConfig.setSystemProperty("aad.response.mode", ""); - } - } - - @Test - public void test_validateState_reportsACallbackThatMatchesNoLogin() { - // The SSO endpoint is anonymous, so anyone can send a state this server never issued. - // SsoAction logs SsoStateException without a stack trace so that cannot fill the log. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - final HttpSession session = newAuthUrlRequest().getSession(); - try { - authenticator.validateState(session, "never-issued"); - fail("expected SsoStateException"); - } catch (final SsoStateException e) { - assertEquals("could not validate state", e.getMessage()); - } - } - - @Test - public void test_validateNonce_reportsAMismatchAsAStateFailure() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - authenticator.validateNonce(new EntraIdAuthenticator.StateData("expected-nonce", 0L), - new TestAuthenticationResult(new TestAccount(), plainIdToken("some-other-nonce"))); - fail("expected SsoStateException"); - } catch (final SsoStateException e) { - assertEquals("could not validate nonce", e.getMessage()); - } - } - - @Test - public void test_validateNonce_acceptsTheNonceItIssued() throws Exception { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - authenticator.validateNonce(new EntraIdAuthenticator.StateData("expected-nonce", 0L), - new TestAuthenticationResult(new TestAccount(), plainIdToken("expected-nonce"))); - } - - @Test - public void test_validateNonce_keepsTheStackTraceOfAnUnreadableIdToken() { - // Only reachable once the authorization code was redeemed, so this is a fault an operator - // has to be able to diagnose -- not a callback someone sent us. It must not be reduced to - // the stack-free SsoStateException log line. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - authenticator.validateNonce(new EntraIdAuthenticator.StateData("expected-nonce", 0L), - new TestAuthenticationResult(new TestAccount(), "not-a-jwt")); - fail("expected SsoLoginException"); - } catch (final SsoStateException e) { - fail("an unreadable ID token must keep its cause: " + e); - } catch (final SsoLoginException e) { - assertNotNull(e.getCause()); - } - } - - /** Builds an unsigned JWT carrying the given nonce; validateNonce only reads the claims. */ - private String plainIdToken(final String nonce) { - final java.util.Base64.Encoder encoder = java.util.Base64.getUrlEncoder().withoutPadding(); - return encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)) + "." - + encoder.encodeToString(("{\"nonce\":\"" + nonce + "\"}").getBytes(StandardCharsets.UTF_8)) + "."; - } - - @Test - public void test_getAuthority_fallsBackWhenTheLegacyKeyIsPresentButBlank() { - // getSystemProperty returns the default only when the key is absent, so an aad.authority - // that exists and is empty came back as "". getAuthUrl then built a scheme-less, and - // therefore relative, URL that redirected the browser back inside Fess. - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - fessConfig.setSystemProperty("aad.authority", ""); - - assertEquals("https://login.microsoftonline.com/", authenticator.getAuthority()); - } finally { - fessConfig.setSystemProperty("aad.authority", ""); - } - } - - @Test - public void test_getResponseMode_ignoresABlankLegacyKey() { - // getSystemProperty only applies the default when the key is absent, so a key left empty - // by the admin screen would otherwise warn about entraid.response.mode on every redirect. - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - fessConfig.setSystemProperty("aad.response.mode", ""); - assertEquals("query", authenticator.getResponseMode()); - } finally { - fessConfig.setSystemProperty("aad.response.mode", ""); - } - } - - @Test - public void test_refresh_doesNotReReadTheDirectoryForAnUnchangedToken() { - // MSAL4J rounds its expiry buffer down to whole seconds, so around REFRESH_MARGIN it - // returns the token it already had. Treating that as a renewal would restore the - // per-request Microsoft Graph call. - final AtomicBoolean updated = new AtomicBoolean(false); - final AtomicReference current = new AtomicReference<>(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - updated.set(true); - } - - @Override - public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) { - return current.get(); - } - }; - ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName()); - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final TestAuthenticationResult sameToken = new TestAuthenticationResult(new TestAccount(), new Date(now + 30 * 1000L)); - current.set(sameToken); - final EntraIdUser user = new EntraIdCredential(sameToken).getUser(); - updated.set(false); - - assertTrue(user.refresh()); - assertFalse(updated.get()); - - current.set(new TestAuthenticationResult(new TestAccount(), new Date(now + 30 * 1000L), "renewed-access-token")); - assertTrue(user.refresh()); - assertTrue(updated.get()); - } - - private HttpServletRequest newAuthUrlRequest() { - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("GET"); - return request; - } - - @Test - public void test_updateMemberOf_marksTheFirstResolutionResolved() { - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("group-a"); - return true; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState()); - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - } - - @Test - public void test_updateMemberOf_marksAFailedFirstResolutionFailed() { - // The login still completes -- the user keeps their user-level permission and the - // configured defaults -- but the shortfall has to be visible rather than silent. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - return false; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - assertNotNull(user.getGroupNames()); - } - - @Test - public void test_updateMemberOf_leavesTheStateAloneOnAReResolution() { - // A token renewal re-resolves a user who already holds working permissions. Neither - // PENDING nor FAILED is true of them, so neither may be written. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - return false; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - user.setGroups(new String[] { "group-a" }); - user.setPermissionState(FessUser.PermissionState.RESOLVED); - user.markResolutionCompleted(); - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - assertEquals("group-a", user.getGroupNames()[0]); - } - - @Test - public void test_updateMemberOf_clearsAFailedStateOnceAReResolutionSucceeds() { - // The user's first lookup failed, so they are FAILED and hold the defaults. A token - // renewal an hour later re-resolves them successfully -- they now genuinely hold their - // groups, and going on telling them their permissions could not be loaded would be a lie. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("group-a"); - return true; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - user.setGroups(new String[] { "everyone" }); - user.setPermissionState(FessUser.PermissionState.FAILED); - user.markResolutionCompleted(); - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - assertEquals("group-a", user.getGroupNames()[0]); - } - - @Test - public void test_constructor_doesNotReachGraphOnTheLoginThread() { - // The login thread must not wait on Microsoft Graph. A slow tenant used to delay every - // login by up to the read timeout. - final AtomicBoolean scheduled = new AtomicBoolean(); - ComponentUtil.register(new EntraIdAuthenticator() { - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - scheduled.set(true); - } - - @Override - public void updateMemberOf(final EntraIdUser user) { - fail("updateMemberOf must not run on the login thread"); - } - }, EntraIdAuthenticator.class.getCanonicalName()); - - final EntraIdUser user = new EntraIdCredential(new TestAuthenticationResult(new TestAccount())).getUser(); - - assertTrue(scheduled.get()); - assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState()); - // Seeded with the configured defaults, of which there are none here -- not left null. - assertEquals(0, user.getGroupNames().length); - assertEquals(0, user.getRoleNames().length); - assertFalse(user.isResolutionCompleted()); - } - - // =================================================================================== - // Defaults During the PENDING Window - // ================================== - // entraid.default.groups/roles are static configuration -- no Graph call stands behind them -- - // so there is no reason for them to be absent while the background resolution runs. SsoAction - // redirects straight to the search page in the request that only schedules it, so without the - // seed the first page after login is that of a user holding no groups at all. - - @Test - public void test_constructor_seedsTheConfiguredDefaultsBeforeAnyResolution() { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - fessConfig.setSystemProperty("entraid.default.roles", "guest"); - - final EntraIdUser user = newUserWithoutGraph(); - - assertEquals(List.of("everyone"), List.of(user.getGroupNames())); - assertEquals(List.of("guest"), List.of(user.getRoleNames())); - // Seeding is not resolving: the state must still say so, and the next updateMemberOf - // must still be treated as the first resolution. - assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState()); - assertFalse(user.isResolutionCompleted()); - } finally { - fessConfig.setSystemProperty("entraid.default.groups", ""); - fessConfig.setSystemProperty("entraid.default.roles", ""); - } - } - - @Test - public void test_getPermissions_appliesTheSeededDefaultsWhileStillPending() { - // The seed is only worth having if it reaches the permissions the search query is built - // from, which are computed lazily and cached. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - - final EntraIdUser user = newUserWithoutGraph(); - - assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState()); - assertTrue(List.of(user.getPermissions()).toString(), - List.of(user.getPermissions()).contains(ComponentUtil.getSystemHelper().getSearchRoleByGroup("everyone"))); - } finally { - fessConfig.setSystemProperty("entraid.default.groups", ""); - } - } - - @Test - public void test_updateMemberOf_writesTheResolvedGroupsOnTopOfTheSeededDefaults() { - // First resolution, Graph answers: the seed is a floor, not a ceiling -- the resolved - // groups join it rather than replacing it or being dropped in favour of it. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("group-a"); - return true; - } - }; - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - final EntraIdUser user = newUserWithoutGraph(); - assertEquals(List.of("everyone"), List.of(user.getGroupNames())); - - authenticator.updateMemberOf(user); - - assertEquals(List.of("everyone", "group-a"), List.of(user.getGroupNames())); - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - assertTrue(user.isResolutionCompleted()); - } finally { - fessConfig.setSystemProperty("entraid.default.groups", ""); - } - } - - @Test - public void test_updateMemberOf_keepsTheFirstResolutionsGroupsWhenALaterOneFails() { - // The re-resolution behaviour driven through the real state machine rather than a - // hand-set flag: one successful resolution, then a token rollover whose lookup fails. - // Nothing about the user may change -- these are the groups they are actually searching - // with, and replacing them with the defaults alone would silently take their results away. - final AtomicBoolean graphAnswers = new AtomicBoolean(true); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - if (!graphAnswers.get()) { - return false; - } - groupList.add("group-a"); - return true; - } - }; - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - assertEquals(List.of("everyone", "group-a"), List.of(user.getGroupNames())); - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - - graphAnswers.set(false); - authenticator.updateMemberOf(user); - - assertEquals(List.of("everyone", "group-a"), List.of(user.getGroupNames())); - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - } finally { - fessConfig.setSystemProperty("entraid.default.groups", ""); - } - } - - @Test - public void test_updateMemberOf_keepsTheSeededDefaultsWhenTheFirstLookupFails() { - // First resolution, Graph does not answer: FAILED, and the seeded defaults are retained - // rather than the user being left with nothing. - // - // This is the case the explicit flag exists for. firstResolution only decides anything on - // the failure path, and with it inferred from `getGroupNames() == null` the seed would - // make this look like a re-resolution: updateMemberOf would take the early return, so the - // user would stay PENDING for the rest of the session -- never marked FAILED, so never - // told their permissions are incomplete -- and whatever the lookup collected before - // failing would be thrown away. - final EntraIdAuthenticator authenticator = newAuthenticatorWhoseLookupFails(); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - try { - fessConfig.setSystemProperty("entraid.default.groups", "everyone"); - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(List.of("everyone"), List.of(user.getGroupNames())); - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - assertTrue(user.isResolutionCompleted()); - } finally { - fessConfig.setSystemProperty("entraid.default.groups", ""); - } - } - - @Test - public void test_updateMemberOf_walksParentGroupsWithoutASecondScheduledTask() { - // The walk used to be a second TimeoutManager task, which published direct-only groups - // first and then overwrote them. One task writes once. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("group-a"); - groupIdsForParentLookup.add("group-a"); - return true; - } - - @Override - protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List roleList, - final String id) { - groupList.add("parent-of-" + id); - return true; - } - - @Override - public void scheduleUpdateMemberOf(final EntraIdUser user) { - fail("updateMemberOf must resolve the parents itself, not schedule a second task"); - } - }; - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - final List groups = List.of(user.getGroupNames()); - assertTrue(groups.contains("group-a")); - assertTrue(groups.contains("parent-of-group-a")); - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - } - - // =================================================================================== - // The Parent Group Walk and the Reported State - // =========================================== - // The direct lookup answering says nothing about the parent groups: the walk that follows it - // fails silently, group by group. A user who holds their direct groups and none of their - // parent groups holds fewer permissions than they should, which is what FAILED is for. - - /** - * A scripted authenticator whose direct lookup succeeds and hands one group id to the parent - * group walk, so that the walk alone decides the state that gets reported. - */ - private ScriptedAuthenticator newAuthenticatorWalkingOneDirectGroup() { - final ScriptedAuthenticator authenticator = new ScriptedAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupList.add("group-a"); - groupIdsForParentLookup.add("group-a"); - return true; - } - }; - authenticator.groupCache = CacheBuilder.newBuilder().build(); - return authenticator; - } - - @Test - public void test_updateMemberOf_reportsAParentWalkTheGraphBackoffSkipped() { - // graphThrottledUntil is a field on the singleton authenticator and is capped at an hour, - // so one user's 429 skips the walk for the whole tenant for that long. The direct lookup - // goes on answering, so every user resolved in that window used to be reported RESOLVED - // while none of them held a single parent group. - final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup(); - authenticator.parents.put("group-a", new String[] { "group-b" }); - ComponentUtil.register(new SystemHelper() { - @Override - public long getCurrentTimeAsLong() { - return clock.get(); - } - }, "systemHelper"); - final EntraIdUser user = newUserWithoutGraph(); - authenticator.graphThrottledUntil = clock.get() + 60_000L; - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - // Skipped, not attempted -- and the direct groups are still written, because degrading is - // the point of the backoff. - assertTrue(authenticator.lookups.isEmpty()); - assertEquals(List.of("group-a"), List.of(user.getGroupNames())); - } - - @Test - public void test_updateMemberOf_reportsAParentWalkWhoseLookupFailed() { - // The other empty pair getParentGroup returns: the cache loader threw, so nothing was - // cached and nothing was resolved. - final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup(); - authenticator.failing.add("group-a"); - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - assertEquals(List.of("group-a"), List.of(user.getGroupNames())); - } - - @Test - public void test_updateMemberOf_resolvesAWalkThatCompleted() { - // The other half of the contract: a walk that reached Graph for every group must not be - // reported as a shortfall, or every Entra ID user would be told their permissions are - // incomplete. - final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup(); - authenticator.parents.put("group-a", new String[] { "group-b" }); - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - assertEquals(List.of("group-a", "group-b"), List.of(user.getGroupNames())); - } - - @Test - public void test_updateMemberOf_doesNotReportTheConfiguredDepthBoundAsAFailure() { - // maxGroupDepth is where the walk is meant to stop, not a Graph failure. Counting it would - // mark every user of a tenant whose nesting is deeper than the bound FAILED for good. - final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup(); - authenticator.parents.put("group-a", new String[] { "group-b" }); - authenticator.maxGroupDepth = 0; - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState()); - assertTrue(authenticator.lookups.isEmpty()); - assertEquals(List.of("group-a"), List.of(user.getGroupNames())); - } - - @Test - public void test_updateMemberOf_notifiesTheActivityLogOncePerCompletedResolution() { - // The notification is the operator's record that a user's permissions changed, and it was - // pinned by nothing: the ActivityHelper stubs in this class were registered only to keep - // ComponentUtil.getActivityHelper() from returning null, so deleting the call from - // updateMemberOf left the whole suite green. - final AtomicBoolean graphAnswers = new AtomicBoolean(true); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - if (!graphAnswers.get()) { - return false; - } - groupList.add("group-a"); - return true; - } - }; - final EntraIdUser user = newUserWithoutGraph(); - // Constructing the user only schedules the resolution, so it has nothing to report yet. - assertEquals(0, permissionChangedCount.get()); - - authenticator.updateMemberOf(user); - - assertEquals(1, permissionChangedCount.get()); - - // A re-resolution Graph did not answer returns before writing anything, so there is no - // change to report either -- the notification belongs after the write, not before it. - graphAnswers.set(false); - authenticator.updateMemberOf(user); - - assertEquals(1, permissionChangedCount.get()); - } - - @Test - public void test_scheduleUpdateMemberOf_marksTheUserFailedWhenUpdateMemberOfThrows() throws Exception { - // scheduleUpdateMemberOf's own body -- the TimeoutManager wiring and the exception - // backstop -- has no coverage otherwise: every other test overrides it away. The stub - // below writes groups and then throws before updateMemberOf would reach the permission - // state write, the exact window Finding M1 is about: a stale "groups == null" check would - // leave this user PENDING forever instead of FAILED. TimeoutManager's loop ticks about - // once a second, so poll for the state with a bounded wait rather than sleeping a fixed - // amount or asserting immediately. - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() { - @Override - public void updateMemberOf(final EntraIdUser user) { - user.setGroups(new String[] { "group-a" }); - throw new RuntimeException("boom"); - } - }; - final EntraIdUser user = newUserWithoutGraph(); - assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState()); - - authenticator.scheduleUpdateMemberOf(user); - - final long deadline = System.currentTimeMillis() + 10_000L; - while (user.getPermissionState() == FessUser.PermissionState.PENDING && System.currentTimeMillis() < deadline) { - Thread.sleep(100L); - } - - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - } - - /** - * Sets the authority and the tenant, reads the joined value and restores both. Both keys are - * written back as "" rather than removed because getSystemProperty keeps them for the life of - * the container; the class asks for a one-time container so that stays inside this class. - */ - private String authorityUrlOf(final String authority, final String tenant) { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - fessConfig.setSystemProperty("entraid.authority", authority); - fessConfig.setSystemProperty("entraid.tenant", tenant); - return authenticator.getAuthorityUrl(); - } finally { - fessConfig.setSystemProperty("entraid.authority", ""); - fessConfig.setSystemProperty("entraid.tenant", ""); - } - } - - @Test - public void test_getAuthorityUrl_keepsASlashTerminatedAuthorityByteIdentical() { - // The normal case, and the shape DEFAULT_AUTHORITY has. It must not move. - assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/", - authorityUrlOf("https://login.microsoftonline.com/", "contoso.onmicrosoft.com")); - } - - @Test - public void test_getAuthorityUrl_insertsTheMissingSeparator() { - // https://login.microsoftonline.com is how the endpoint is written everywhere it is - // documented, so an admin typing it without the trailing slash is the expected mistake. - // Concatenated raw it produced login.microsoftonline.comcontoso.onmicrosoft.com -- one - // bogus hostname -- and the browser got NXDOMAIN. - assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/", - authorityUrlOf("https://login.microsoftonline.com", "contoso.onmicrosoft.com")); - } - - @Test - public void test_getAuthorityUrl_leavesASlashPrefixedTenantAlone() { - // A slashless authority plus a slash-prefixed tenant already joins correctly today. A fix - // that always appends a slash to the authority would break this working configuration. - assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/", - authorityUrlOf("https://login.microsoftonline.com", "/contoso.onmicrosoft.com")); - } - - @Test - public void test_getAuthorityUrl_collapsesADoubledSeparator() { - assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/", - authorityUrlOf("https://login.microsoftonline.com/", "/contoso.onmicrosoft.com")); - } - - @Test - public void test_getAuthorityUrl_leavesABlankTenantByteIdentical() { - // validateConfiguration refuses a blank tenant before any of this is reached on the login - // path, and the doubled slash below is the symptom its own javadoc quotes. Normalising it - // here would only change a URL an operator has already been told about. - assertEquals("https://login.microsoftonline.com//", authorityUrlOf("https://login.microsoftonline.com/", "")); - assertEquals("https://login.microsoftonline.com/", authorityUrlOf("https://login.microsoftonline.com", "")); - } - - @Test - public void test_getAuthorityUrl_doesNotLowercaseTheAuthority() { - assertEquals("https://Login.MicrosoftOnline.com/Contoso.onmicrosoft.com/", - authorityUrlOf("https://Login.MicrosoftOnline.com", "Contoso.onmicrosoft.com")); - } - - @Test - public void test_getAuthUrl_separatesASlashlessAuthorityFromTheTenant() { - // Pins the whole authorization URL, not just the join: the failure was silent because the - // URL is only logged at debug level, so the browser was the first thing to see it. - ComponentUtil.register(new SystemHelper(), "systemHelper"); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final EntraIdAuthenticator authenticator = new EntraIdAuthenticator(); - try { - fessConfig.setSystemProperty("entraid.authority", "https://login.microsoftonline.com"); - fessConfig.setSystemProperty("entraid.tenant", "contoso.onmicrosoft.com"); - fessConfig.setSystemProperty("entraid.client.id", "11111111-1111-1111-1111-111111111111"); - fessConfig.setSystemProperty("entraid.reply.url", "https://fess.example.com/sso/"); - - final String authUrl = authenticator.getAuthUrl(newAuthUrlRequest()); - - final String expected = - "https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize" + "?response_type=code&scope=" - + URLEncoder.encode("openid profile offline_access https://graph.microsoft.com/.default", - StandardCharsets.UTF_8) - + "&response_mode=query&redirect_uri=" - + URLEncoder.encode("https://fess.example.com/sso/", StandardCharsets.UTF_8) - + "&client_id=11111111-1111-1111-1111-111111111111"; - // state and nonce are random per call; everything before them is fixed. - assertEquals(expected, authUrl.replaceFirst("&state=.*$", "")); - } finally { - fessConfig.setSystemProperty("entraid.authority", ""); - fessConfig.setSystemProperty("entraid.tenant", ""); - fessConfig.setSystemProperty("entraid.client.id", ""); - fessConfig.setSystemProperty("entraid.reply.url", ""); - } - } - - /** - * An authenticator whose direct membership lookup hands back a fixed set of group ids without - * reaching Microsoft Graph, and whose parent group walk is scripted by {@code walkResult}. - * Every id the walk is asked for is appended to {@code walked}, so a test can tell how far the - * walk got as well as what it produced. - */ - private EntraIdAuthenticator newAuthenticatorWithScriptedWalk(final List groupIds, final List walked, - final java.util.function.Predicate walkResult, final boolean throttled) { - return new EntraIdAuthenticator() { - @Override - protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList, - final List groupIdsForParentLookup, final String url) { - groupIdsForParentLookup.addAll(groupIds); - groupList.add("direct-group"); - return true; - } - - @Override - protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List roleList, - final String id) { - walked.add(id); - if (walkResult.test(id)) { - groupList.add("parent-of-" + id); - return true; - } - return false; - } - - @Override - protected boolean isGraphThrottled() { - return throttled; - } - }; - } - - @Test - public void test_updateMemberOf_stopsTheWalkAfterConsecutiveGraphFailures() { - // Graph answers /me/memberOf and then fails every getMemberGroups with something that - // records no backoff -- a 500/502/504, or a transport failure such as DNS, connection - // refused or the graphConnectTimeout/graphReadTimeout expiring. Without the bound that is - // one request, one waited-out timeout and one stack trace per direct group, on every - // login, on the shared TimeoutManager pool. - final List walked = new ArrayList<>(); - final EntraIdAuthenticator authenticator = - newAuthenticatorWithScriptedWalk(List.of("g1", "g2", "g3", "g4", "g5", "g6"), walked, id -> false, false); - final EntraIdUser user = newUserWithoutGraph(); - final int before = permissionChangedCount.get(); - - authenticator.updateMemberOf(user); - - assertEquals(3, walked.size(), "the walk has to stop at maxConsecutiveGroupLookupFailures"); - assertEquals(List.of("g1", "g2", "g3"), walked); - // Still applied, and still announced: a partial parent set is worth more than none, and - // FAILED is what tells the user their permissions fell short. - assertTrue(List.of(user.getGroupNames()).contains("direct-group")); - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - assertEquals(before + 1, permissionChangedCount.get()); - } - - @Test - public void test_updateMemberOf_letsASuccessResetTheFailureCounter() { - // Consecutive, not total: one group id that is permanently broken -- deleted, or one the - // application has no permission for -- must not stop the rest of the walk. - final List walked = new ArrayList<>(); - final EntraIdAuthenticator authenticator = newAuthenticatorWithScriptedWalk(List.of("g1", "g2", "g3", "g4", "g5", "g6"), walked, - id -> "g3".equals(id) || "g6".equals(id), false); - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(6, walked.size(), "a success between failures has to clear the counter"); - assertTrue(List.of(user.getGroupNames()).contains("parent-of-g3")); - assertTrue(List.of(user.getGroupNames()).contains("parent-of-g6")); - // Some parents were still missed, so the user is not fully resolved. - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - } - - @Test - public void test_updateMemberOf_doesNotCountAThrottledSkipTowardsTheBound() { - // A lookup skipped for the tenant-wide backoff never reaches Graph, so it costs nothing - // and the backoff already bounds it. Counting it would end the walk -- and log the WARN -- - // on every login for as long as the throttle lasts, buying nothing. - final List walked = new ArrayList<>(); - final EntraIdAuthenticator authenticator = - newAuthenticatorWithScriptedWalk(List.of("g1", "g2", "g3", "g4", "g5", "g6"), walked, id -> false, true); - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(6, walked.size(), "a throttled skip must not consume the bound"); - assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState()); - } - - @Test - public void test_updateMemberOf_honoursAConfiguredFailureBound() { - // The bound is a fess_sso++.xml property, so an operator can widen it for a tenant whose - // groups genuinely fail one by one, or narrow it to 1 to give up at the first failure. - final List walked = new ArrayList<>(); - final EntraIdAuthenticator authenticator = - newAuthenticatorWithScriptedWalk(List.of("g1", "g2", "g3", "g4", "g5", "g6"), walked, id -> false, false); - authenticator.setMaxConsecutiveGroupLookupFailures(1); - final EntraIdUser user = newUserWithoutGraph(); - - authenticator.updateMemberOf(user); - - assertEquals(1, walked.size()); - } -} diff --git a/src/test/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticatorTest.java b/src/test/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticatorTest.java deleted file mode 100644 index fc369e477d..0000000000 --- a/src/test/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticatorTest.java +++ /dev/null @@ -1,537 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.oic; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.core.LogEvent; -import org.codelibs.core.io.FileUtil; -import org.codelibs.core.misc.DynamicProperties; -import org.codelibs.fess.app.web.base.login.ActionResponseCredential; -import org.codelibs.fess.unit.LogCapturingAppender; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.dbflute.utflute.mocklet.MockletHttpServletRequest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; -import org.lastaflute.web.login.credential.LoginCredential; - -import com.google.api.client.auth.oauth2.TokenResponse; - -import jakarta.servlet.http.HttpServletRequest; - -/** - * Unit tests for {@link OpenIdConnectAuthenticator}. - * Tests JWT parsing, Base64 decoding, and configuration handling. - */ -public class OpenIdConnectAuthenticatorTest extends UnitFessTestCase { - - private OpenIdConnectAuthenticator authenticator; - private DynamicProperties systemProperties; - - @Override - protected void setUp(TestInfo testInfo) throws Exception { - super.setUp(testInfo); - authenticator = new OpenIdConnectAuthenticator(); - final File propFile = File.createTempFile("oic_test", ".properties"); - propFile.deleteOnExit(); - FileUtil.writeBytes(propFile.getAbsolutePath(), "".getBytes("UTF-8")); - systemProperties = new DynamicProperties(propFile); - ComponentUtil.register(systemProperties, "systemProperties"); - } - - @Test - public void test_decodeBase64_null() { - assertNull(authenticator.decodeBase64(null)); - } - - @Test - public void test_decodeBase64_standard() { - // "Hello" encoded in standard Base64 - final byte[] result = authenticator.decodeBase64("SGVsbG8="); - assertEquals("Hello", new String(result)); - } - - @Test - public void test_decodeBase64_urlSafe() { - // Base64 URL encoding (uses - and _ instead of + and /) - final byte[] result = authenticator.decodeBase64("SGVsbG9Xb3JsZA"); - assertEquals("HelloWorld", new String(result)); - } - - @Test - public void test_decodeBase64_withPadding() { - // Standard Base64 with padding - final byte[] result = authenticator.decodeBase64("dGVzdA=="); - assertEquals("test", new String(result)); - } - - @Test - public void test_parseJwtClaim_simpleValues() throws IOException { - final String jwtClaim = "{\"sub\":\"user123\",\"name\":\"John Doe\",\"email\":\"john@example.com\"}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertEquals("user123", attributes.get("sub")); - assertEquals("John Doe", attributes.get("name")); - assertEquals("john@example.com", attributes.get("email")); - } - - @Test - public void test_parseJwtClaim_numericValues() throws IOException { - final String jwtClaim = "{\"iat\":1609459200,\"exp\":1609462800,\"nbf\":1609459200}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertEquals(1609459200L, attributes.get("iat")); - assertEquals(1609462800L, attributes.get("exp")); - assertEquals(1609459200L, attributes.get("nbf")); - } - - @Test - public void test_parseJwtClaim_booleanValues() throws IOException { - final String jwtClaim = "{\"email_verified\":true,\"active\":false}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertEquals(true, attributes.get("email_verified")); - assertEquals(false, attributes.get("active")); - } - - @Test - public void test_parseJwtClaim_nullValue() throws IOException { - final String jwtClaim = "{\"optional_claim\":null}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertTrue(attributes.containsKey("optional_claim")); - assertNull(attributes.get("optional_claim")); - } - - @Test - public void test_parseJwtClaim_arrayValues() throws IOException { - final String jwtClaim = "{\"roles\":[\"admin\",\"user\"],\"groups\":[\"group1\",\"group2\"]}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertTrue(attributes.get("roles") instanceof List); - @SuppressWarnings("unchecked") - final List roles = (List) attributes.get("roles"); - assertEquals(2, roles.size()); - assertEquals("admin", roles.get(0)); - assertEquals("user", roles.get(1)); - } - - @Test - public void test_parseJwtClaim_nestedObject() throws IOException { - final String jwtClaim = "{\"address\":{\"street\":\"123 Main St\",\"city\":\"Springfield\"}}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertTrue(attributes.get("address") instanceof Map); - @SuppressWarnings("unchecked") - final Map address = (Map) attributes.get("address"); - assertEquals("123 Main St", address.get("street")); - assertEquals("Springfield", address.get("city")); - } - - @Test - public void test_parseJwtClaim_floatValue() throws IOException { - final String jwtClaim = "{\"score\":95.5}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertEquals(95.5, attributes.get("score")); - } - - @Test - public void test_parseJwtClaim_emptyObject() throws IOException { - final String jwtClaim = "{}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertTrue(attributes.isEmpty()); - } - - @Test - public void test_parseJwtClaim_complexStructure() throws IOException { - final String jwtClaim = "{\"user\":{\"id\":123,\"roles\":[\"admin\",\"user\"],\"permissions\":{\"read\":true,\"write\":false}}}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertTrue(attributes.containsKey("user")); - @SuppressWarnings("unchecked") - final Map user = (Map) attributes.get("user"); - assertEquals(123L, user.get("id")); - - @SuppressWarnings("unchecked") - final List userRoles = (List) user.get("roles"); - assertEquals(2, userRoles.size()); - - @SuppressWarnings("unchecked") - final Map permissions = (Map) user.get("permissions"); - assertEquals(true, permissions.get("read")); - assertEquals(false, permissions.get("write")); - } - - @Test - public void test_getOicAuthServerUrl_default() { - final String url = authenticator.getOicAuthServerUrl(); - assertEquals("https://accounts.google.com/o/oauth2/auth", url); - } - - @Test - public void test_getOicTokenServerUrl_default() { - final String url = authenticator.getOicTokenServerUrl(); - assertEquals("https://accounts.google.com/o/oauth2/token", url); - } - - @Test - public void test_getOicClientId_default() { - final String clientId = authenticator.getOicClientId(); - assertEquals("", clientId); - } - - @Test - public void test_getOicClientSecret_default() { - final String secret = authenticator.getOicClientSecret(); - assertEquals("", secret); - } - - @Test - public void test_getOicScope_default() { - final String scope = authenticator.getOicScope(); - assertEquals("", scope); - } - - @Test - public void test_buildDefaultRedirectUrl_noBaseUrl() { - final String url = authenticator.buildDefaultRedirectUrl(); - assertEquals("http://localhost:8080/sso/", url); - } - - @Test - public void test_logout_returnsNull() { - assertNull(authenticator.logout(null)); - } - - @Test - public void test_getResponse_returnsNull() { - assertNull(authenticator.getResponse(null)); - } - - @Test - public void test_getLoginCredential_withRequest() { - // With a request context, should return ActionResponseCredential for OAuth redirect - final var credential = authenticator.getLoginCredential(); - assertNotNull(credential); - assertTrue(credential instanceof ActionResponseCredential); - } - - @Test - public void test_getAuthUrl_issuesAnUnguessableState() { - // The state is the only thing standing between a login and a forged callback - // (RFC 6749 section 10.12), and org.codelibs.core.net.UuidUtil -- which getAuthUrl used - // to call -- is hex(localIP) + hex(identityHashCode(RANDOM)) + - // hex((int) (currentTimeMillis() >> 32)) + hex(SecureRandom.nextInt()): the first 16 hex - // characters never change within a JVM and the timestamp word moves every ~49.7 days, so - // under 32 bits actually varied per call. - final Set states = new HashSet<>(); - final Set prefixes = new HashSet<>(); - for (int i = 0; i < 200; i++) { - final HttpServletRequest request = getMockRequest(); - authenticator.getAuthUrl(request); - // getAuthUrl stashes the same value it puts in the URL, and getLoginCredential only - // ever compares the two with equals(), so nothing depends on its length or format. - final String state = (String) request.getSession().getAttribute(OpenIdConnectAuthenticator.OIC_STATE); - assertNotNull(state, "no state was stored in the session"); - states.add(state); - prefixes.add(state.replace("-", "").substring(0, 16)); - } - - assertEquals(200, states.size()); - // The whole point: a fixed leading half is what UuidUtil produced. - assertTrue("distinct prefixes: " + prefixes.size(), prefixes.size() > 190); - } - - @Test - public void test_parseJwtClaim_nestedArray() throws IOException { - final String jwtClaim = "{\"matrix\":[[1,2],[3,4]]}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertTrue(attributes.get("matrix") instanceof List); - @SuppressWarnings("unchecked") - final List matrix = (List) attributes.get("matrix"); - assertEquals(2, matrix.size()); - - @SuppressWarnings("unchecked") - final List row1 = (List) matrix.get(0); - assertEquals(1L, row1.get(0)); - assertEquals(2L, row1.get(1)); - } - - @Test - public void test_parseJwtClaim_mixedArray() throws IOException { - final String jwtClaim = "{\"mixed\":[\"string\",123,true,null]}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - @SuppressWarnings("unchecked") - final List mixed = (List) attributes.get("mixed"); - assertEquals(4, mixed.size()); - assertEquals("string", mixed.get(0)); - assertEquals(123L, mixed.get(1)); - assertEquals(true, mixed.get(2)); - assertNull(mixed.get(3)); - } - - @Test - public void test_parseJwtClaim_standardOidcClaims() throws IOException { - final String jwtClaim = "{" + "\"iss\":\"https://issuer.example.com\"," + "\"sub\":\"user@example.com\"," - + "\"aud\":\"client-123\"," + "\"exp\":1700000000," + "\"iat\":1699999900," + "\"nonce\":\"abc123\"," - + "\"at_hash\":\"hashvalue\"," + "\"c_hash\":\"codehash\"" + "}"; - final Map attributes = new HashMap<>(); - - authenticator.parseJwtClaim(jwtClaim, attributes); - - assertEquals("https://issuer.example.com", attributes.get("iss")); - assertEquals("user@example.com", attributes.get("sub")); - assertEquals("client-123", attributes.get("aud")); - assertEquals(1700000000L, attributes.get("exp")); - assertEquals(1699999900L, attributes.get("iat")); - assertEquals("abc123", attributes.get("nonce")); - assertEquals("hashvalue", attributes.get("at_hash")); - assertEquals("codehash", attributes.get("c_hash")); - } - - // =================================================================================== - // Callback failure handling - // ========================= - - private static String segment(final String json) { - return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); - } - - private static String jwtOf(final String claimJson) { - return segment("{\"alg\":\"RS256\"}") + "." + segment(claimJson) + "." + segment("signature"); - } - - private static TokenResponse tokenResponseWith(final Object idToken) { - final TokenResponse tr = new TokenResponse(); - tr.setAccessToken("access-token"); - tr.setTokenType("Bearer"); - tr.setExpiresInSeconds(300L); - if (idToken != null) { - tr.set("id_token", idToken); - } - return tr; - } - - private OpenIdConnectAuthenticator authenticatorReturning(final TokenResponse tr) { - return new OpenIdConnectAuthenticator() { - @Override - protected TokenResponse getTokenUrl(final String code) { - return tr; - } - }; - } - - private LoginCredential callbackWith(final Object idToken) { - return authenticatorReturning(tokenResponseWith(idToken)).processCallback(getMockRequest(), "the-code"); - } - - @Test - public void test_processCallback_acceptsAWellFormedIdToken() { - final LoginCredential credential = callbackWith(jwtOf("{\"email\":\"user@example.com\"}")); - assertNotNull(credential); - assertEquals("{user@example.com}", credential.toString()); - } - - @Test - public void test_processCallback_withoutIdToken() { - // A token response that carries no id_token used to reach ((String) null).split and throw. - assertNull(callbackWith(null)); - } - - @Test - public void test_processCallback_withNonStringIdToken() { - assertNull(callbackWith(Long.valueOf(42))); - } - - @Test - public void test_processCallback_withBlankIdToken() { - assertNull(callbackWith("")); - } - - @Test - public void test_processCallback_withTwoSegmentIdToken() { - // jwt[2] used to throw ArrayIndexOutOfBoundsException, which no caller catches. - assertNull(callbackWith("header.claim")); - } - - @Test - public void test_processCallback_withFourSegmentIdToken() { - // A JWE compact serialisation has five segments and is not a signed JWT either. - assertNull(callbackWith("a.b.c.d")); - } - - @Test - public void test_processCallback_withUndecodableSegment() { - // decodeBase64 throws IllegalArgumentException, which only the IOException catch used to cover. - assertNull(callbackWith("aGVhZGVy.!!!not-base64!!!.c2ln")); - } - - @Test - public void test_processCallback_withNonJsonClaim() { - assertNull(callbackWith(segment("{\"alg\":\"RS256\"}") + "." + segment("not json at all") + "." + segment("s"))); - } - - @Test - public void test_processCallback_withoutEmailClaim() { - // The email claim is the user id. A credential without one logs in as a null-named user and - // then fails on every later request, so it must not become a session at all. - assertNull(callbackWith(jwtOf("{\"sub\":\"1234\",\"groups\":[\"dev\"]}"))); - } - - @Test - public void test_processCallback_withBlankEmailClaim() { - assertNull(callbackWith(jwtOf("{\"email\":\"\"}"))); - } - - @Test - public void test_getLoginCredential_withProviderErrorResponse() { - // error=access_denied with the state we issued means the provider refused this login. Starting - // another authorization request would loop against a provider that keeps refusing, and would - // override the user's own refusal against one that does not. - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(OpenIdConnectAuthenticator.OIC_STATE, "the-state"); - request.setParameter("state", "the-state"); - request.setParameter("error", "access_denied"); - request.setParameter("error_description", "The user declined"); - - assertNull(authenticator.getLoginCredential()); - assertNull(request.getSession().getAttribute(OpenIdConnectAuthenticator.OIC_STATE)); - } - - @Test - public void test_getLoginCredential_withErrorForAnotherState() { - // A state that is not the one in the session is not this login's error response, so the - // existing behaviour -- start a fresh authorization request -- is kept. - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(OpenIdConnectAuthenticator.OIC_STATE, "the-state"); - request.setParameter("state", "a-different-state"); - request.setParameter("error", "access_denied"); - - final LoginCredential credential = authenticator.getLoginCredential(); - assertNotNull(credential); - assertTrue(credential instanceof ActionResponseCredential); - } - - @Test - public void test_getLoginCredential_withoutCodeOrError() { - // A bare callback with a matching state and neither parameter still restarts the flow. - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(OpenIdConnectAuthenticator.OIC_STATE, "the-state"); - request.setParameter("state", "the-state"); - - final LoginCredential credential = authenticator.getLoginCredential(); - assertNotNull(credential); - assertTrue(credential instanceof ActionResponseCredential); - } - - // =================================================================================== - // Debug log confidentiality - // ========================= - - private static String segment(final byte[] raw) { - return Base64.getUrlEncoder().withoutPadding().encodeToString(raw); - } - - /** - * Drives processCallback with a token response the test controls and returns everything this - * class logged while doing it. - */ - private String debugOutputOfCallback(final TokenResponse tr) { - final LogCapturingAppender appender = LogCapturingAppender.attach(OpenIdConnectAuthenticator.class.getName(), Level.DEBUG); - try { - authenticatorReturning(tr).processCallback(getMockRequest(), "the-code"); - } finally { - appender.detach(); - } - final StringBuilder buf = new StringBuilder(); - for (final LogEvent event : appender.events()) { - buf.append(event.getMessage().getFormattedMessage()).append('\n'); - } - return buf.toString(); - } - - private static TokenResponse secretCarryingTokenResponse() { - final TokenResponse tr = new TokenResponse(); - tr.setAccessToken("ACCESS-TOKEN-MUST-NOT-BE-LOGGED"); - tr.setRefreshToken("REFRESH-TOKEN-MUST-NOT-BE-LOGGED"); - tr.setTokenType("Bearer"); - tr.setExpiresInSeconds(300L); - // A signature is raw bytes; these are not valid UTF-8 text. - final byte[] signature = { 0x00, 0x01, (byte) 0xC3, (byte) 0x28, (byte) 0xA0, (byte) 0xA1, 0x07 }; - tr.set("id_token", segment("{\"alg\":\"RS256\"}") + "." + segment("{\"email\":\"user@example.com\"}") + "." + segment(signature)); - return tr; - } - - @Test - public void test_processCallback_doesNotLogTheAccessOrRefreshToken() { - // The documentation tells administrators to raise this logger to debug when a login - // misbehaves, so anything it prints reaches log files, issue reports and log collectors. - final String output = debugOutputOfCallback(secretCarryingTokenResponse()); - - assertFalse(output.contains("ACCESS-TOKEN-MUST-NOT-BE-LOGGED"), "the access token was logged"); - assertFalse(output.contains("REFRESH-TOKEN-MUST-NOT-BE-LOGGED"), "the refresh token was logged"); - // What is actually needed to diagnose a login is still there. - assertTrue(output.contains("user@example.com"), "the claim set was not logged"); - assertTrue(output.contains("Bearer"), "the token type was not logged"); - } - - @Test - public void test_processCallback_doesNotLogRawSignatureBytes() { - final String output = debugOutputOfCallback(secretCarryingTokenResponse()); - - // A single invalid byte in fess.log makes the whole file count as binary, and grep and the - // rest of the usual log tooling then skip it without saying so. - assertFalse(output.contains("\u0000"), "a NUL byte reached the log"); - assertFalse(output.contains("\u0007"), "a control byte reached the log"); - assertFalse(output.contains("\ufffd"), "an undecodable byte reached the log"); - } -} diff --git a/src/test/java/org/codelibs/fess/sso/saml/SamlAuthenticatorTest.java b/src/test/java/org/codelibs/fess/sso/saml/SamlAuthenticatorTest.java deleted file mode 100644 index 63574c22a1..0000000000 --- a/src/test/java/org/codelibs/fess/sso/saml/SamlAuthenticatorTest.java +++ /dev/null @@ -1,2025 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.saml; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.security.KeyPairGenerator; -import java.util.ArrayList; -import java.util.Base64; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.stream.Collectors; - -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.core.LogEvent; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.core.misc.DynamicProperties; -import org.codelibs.fess.app.web.base.login.ActionResponseCredential; -import org.codelibs.fess.app.web.base.login.SamlCredential.SamlUser; -import org.codelibs.fess.entity.FessUser; -import org.codelibs.fess.exception.SsoMessageException; -import org.codelibs.fess.exception.SsoStateException; -import org.codelibs.fess.helper.SystemHelper; -import org.codelibs.fess.mylasta.action.FessUserBean; -import org.codelibs.fess.sso.SsoResponseType; -import org.codelibs.fess.unit.LogCapturingAppender; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.saml2.core.exception.SAMLException; -import org.codelibs.saml2.core.exception.ValidationException; -import org.codelibs.saml2.core.exception.XMLParsingException; -import org.codelibs.saml2.core.settings.Saml2Settings; -import org.dbflute.optional.OptionalThing; -import org.dbflute.utflute.mocklet.MockletHttpServletRequest; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.lastaflute.web.login.credential.LoginCredential; -import org.lastaflute.web.response.ActionResponse; -import org.lastaflute.web.response.StreamResponse; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; - -public class SamlAuthenticatorTest extends UnitFessTestCase { - - private static final String BASE_URL_KEY = "saml.sp.base.url"; - - /** - * Builds an authenticator whose defaultSettings come from the production code. - * init() is not usable here because it registers the instance with the SsoManager. - */ - private SamlAuthenticator createAuthenticator() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - final Field field = SamlAuthenticator.class.getDeclaredField("defaultSettings"); - field.setAccessible(true); - field.set(authenticator, authenticator.createDefaultSettings()); - return authenticator; - } - - @Test - public void test_createDefaultSettings_security() throws Exception { - final Map settings = new SamlAuthenticator().createDefaultSettings(); - - assertEquals("true", settings.get("onelogin.saml2.strict")); - assertEquals("false", settings.get("onelogin.saml2.debug")); - assertEquals("true", settings.get("onelogin.saml2.security.want_xml_validation")); - assertEquals("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", settings.get("onelogin.saml2.security.signature_algorithm")); - // the key must not carry a duplicated prefix, otherwise it is silently ignored - assertEquals("exact", settings.get("onelogin.saml2.security.requested_authncontextcomparison")); - } - - @Test - public void test_createDefaultSettings_hasNoBlankValues() throws Exception { - // SettingsBuilder treats blank values as absent, so a blank default is dead weight - new SamlAuthenticator().createDefaultSettings().forEach((key, value) -> { - assertTrue(key + " must not have a blank default", StringUtil.isNotBlank((String) value)); - }); - } - - @Test - public void test_createDefaultSettings_omitsSpUrls() throws Exception { - // SP URLs depend on saml.sp.base.url and are therefore built per request - final Map settings = new SamlAuthenticator().createDefaultSettings(); - - assertFalse(settings.containsKey("onelogin.saml2.sp.entityid")); - assertFalse(settings.containsKey("onelogin.saml2.sp.assertion_consumer_service.url")); - assertFalse(settings.containsKey("onelogin.saml2.sp.single_logout_service.url")); - } - - @Test - public void test_getSettings_spUrlsUseDefaultBaseUrl() throws Exception { - final Saml2Settings settings = createAuthenticator().getSettings(); - - assertEquals("http://localhost:8080/sso/metadata", settings.getSpEntityId()); - assertEquals("http://localhost:8080/sso/", settings.getSpAssertionConsumerServiceUrl().toString()); - assertEquals("http://localhost:8080/sso/logout", settings.getSpSingleLogoutServiceUrl().toString()); - } - - @Test - public void test_getSettings_spUrlsFollowBaseUrlChangedAfterStartup() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // the property is changed after the authenticator was built, as the admin UI does - systemProperties.setProperty(BASE_URL_KEY, "https://fess.example.com"); - - final Saml2Settings settings = authenticator.getSettings(); - - assertEquals("https://fess.example.com/sso/metadata", settings.getSpEntityId()); - assertEquals("https://fess.example.com/sso/", settings.getSpAssertionConsumerServiceUrl().toString()); - assertEquals("https://fess.example.com/sso/logout", settings.getSpSingleLogoutServiceUrl().toString()); - } finally { - systemProperties.remove(BASE_URL_KEY); - } - } - - @Test - public void test_getSettings_blankPropertyKeepsDefault() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - systemProperties.setProperty("saml.sp.entityid", StringUtil.EMPTY); - systemProperties.setProperty("saml.sp.assertion_consumer_service.url", " "); - - final Saml2Settings settings = authenticator.getSettings(); - - assertEquals("http://localhost:8080/sso/metadata", settings.getSpEntityId()); - assertEquals("http://localhost:8080/sso/", settings.getSpAssertionConsumerServiceUrl().toString()); - assertTrue(settings.checkSPSettings().isEmpty()); - } finally { - systemProperties.remove("saml.sp.entityid"); - systemProperties.remove("saml.sp.assertion_consumer_service.url"); - } - } - - @Test - public void test_getSettings_blankPropertyFallsBackToLibraryDefault() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // clearing the key is the documented way of not constraining the authentication - // method; falling back to the Fess default instead would keep sending - // RequestedAuthnContext=Password and break an IdP that enforces MFA - systemProperties.setProperty("saml.security.requested_authncontext", StringUtil.EMPTY); - systemProperties.setProperty("saml.sp.nameidformat", " "); - - final Saml2Settings settings = authenticator.getSettings(); - - assertTrue(String.valueOf(settings.getRequestedAuthnContext()), settings.getRequestedAuthnContext().isEmpty()); - assertEquals("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", settings.getSpNameIDFormat()); - } finally { - systemProperties.remove("saml.security.requested_authncontext"); - systemProperties.remove("saml.sp.nameidformat"); - } - } - - @Test - public void test_getSettings_cachedUntilPropertiesChange() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - final Saml2Settings first = authenticator.getSettings(); - final Saml2Settings second = authenticator.getSettings(); - - // rebuilding re-parses the IdP certificate and makes the library re-emit one warn - // line per security warning, so unchanged properties must reuse the instance - assertSame(first, second); - assertEquals(1, appender.warnings().size()); - - systemProperties.setProperty("saml.security.want_assertions_signed", "true"); - final Saml2Settings rebuilt = authenticator.getSettings(); - - Assertions.assertNotSame(first, rebuilt); - assertTrue(rebuilt.getWantAssertionsSigned()); - } finally { - systemProperties.remove("saml.security.want_assertions_signed"); - appender.detach(); - } - } - - @Test - public void test_getSettings_cacheFollowsBaseUrlChangedAfterStartup() throws Exception { - // the computed SP URLs are not system properties, so the cache key has to include them - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - assertEquals("http://localhost:8080/sso/metadata", authenticator.getSettings().getSpEntityId()); - - systemProperties.setProperty(BASE_URL_KEY, "https://fess.example.com"); - - assertEquals("https://fess.example.com/sso/metadata", authenticator.getSettings().getSpEntityId()); - } finally { - systemProperties.remove(BASE_URL_KEY); - } - } - - /** - * Generates a throwaway SP private key in the shape - * {@code onelogin.saml2.sp.privatekey} expects: base64 PKCS#8, no PEM header. - * - *

The key is generated rather than checked in so that no private key material - * lives in the repository.

- * - * @return the encoded private key - * @throws Exception if the key cannot be generated - */ - private static String generateSpPrivateKey() throws Exception { - final KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(2048); - return java.util.Base64.getEncoder().encodeToString(generator.generateKeyPair().getPrivate().getEncoded()); - } - - @Test - public void test_warnIfMetadataCannotBeSigned_reportsSilentlyUnsignedMetadata() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - // signing not requested: nothing to report whatever the key material is - authenticator.warnIfMetadataCannotBeSigned(authenticator.getSettings()); - assertTrue(appender.warnings().toString(), appender.warnings().stream().noneMatch(w -> w.contains("sign_metadata"))); - - systemProperties.setProperty("saml.security.sign_metadata", "true"); - authenticator.warnIfMetadataCannotBeSigned(authenticator.getSettings()); - - // getSPMetadata() swallows the signing failure at debug level and returns the - // unsigned document, so without this warning the downgrade is invisible - final String bothMissing = lastWarning(appender, "sign_metadata"); - assertTrue(bothMissing, bothMissing.contains("saml.sp.privatekey, saml.sp.x509cert")); - - systemProperties.setProperty("saml.sp.privatekey", generateSpPrivateKey()); - authenticator.warnIfMetadataCannotBeSigned(authenticator.getSettings()); - final String certMissing = lastWarning(appender, "sign_metadata"); - assertTrue(certMissing, certMissing.contains("missing: saml.sp.x509cert.")); - } finally { - systemProperties.remove("saml.security.sign_metadata"); - systemProperties.remove("saml.sp.privatekey"); - appender.detach(); - } - } - - /** - * The most recent captured warning containing {@code needle}, or the whole capture - * rendered as text so a failing assertion says what was logged instead. - * - * @param appender the capturing appender - * @param needle the substring to look for - * @return the matching warning, or the full capture when nothing matched - */ - private static String lastWarning(final LogCapturingAppender appender, final String needle) { - String found = null; - for (final String w : appender.warnings()) { - if (w.contains(needle)) { - found = w; - } - } - return found != null ? found : ("no warning contained " + needle + ": " + appender.warnings()); - } - - @Test - public void test_logSecurityWarnings_reportsUnrestrictedKeyTransport() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - // no SP private key: nothing can be decrypted, so the allow-list is moot - authenticator.getSettings(); - assertEquals(1, appender.warnings().size()); - assertFalse(appender.warnings().get(0), appender.warnings().get(0).contains("key_transport_algorithms_not_restricted")); - - systemProperties.setProperty("saml.sp.privatekey", generateSpPrivateKey()); - authenticator.getSettings(); - - // a key is configured and every key transport algorithm is accepted - assertEquals(2, appender.warnings().size()); - assertTrue(appender.warnings().get(1), appender.warnings().get(1).contains("key_transport_algorithms_not_restricted")); - - systemProperties.setProperty("saml.security.allowed_key_transport_algorithms", "http://www.w3.org/2009/xmlenc11#rsa-oaep"); - authenticator.getSettings(); - - assertEquals(3, appender.warnings().size()); - assertFalse(appender.warnings().get(2), appender.warnings().get(2).contains("key_transport_algorithms_not_restricted")); - - // a blank value is not a restriction: the library treats an empty set as "accept - // everything", so the warning has to come back - systemProperties.setProperty("saml.security.allowed_key_transport_algorithms", ""); - authenticator.getSettings(); - - assertEquals(4, appender.warnings().size()); - assertTrue(appender.warnings().get(3), appender.warnings().get(3).contains("key_transport_algorithms_not_restricted")); - } finally { - systemProperties.remove("saml.sp.privatekey"); - systemProperties.remove("saml.security.allowed_key_transport_algorithms"); - appender.detach(); - } - } - - @Test - public void test_logSecurityWarnings_reportsUnsignedLogoutRequests() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - // without a single logout service there is nothing to send a LogoutRequest to - authenticator.getSettings(); - assertEquals(1, appender.warnings().size()); - assertFalse(appender.warnings().get(0), appender.warnings().get(0).contains("unsigned_logoutrequest_accepted")); - - systemProperties.setProperty("saml.idp.single_logout_service.url", "https://idp.example.com/slo"); - authenticator.getSettings(); - - // an unsigned LogoutRequest whose NameID is never checked ends any session - assertEquals(2, appender.warnings().size()); - assertTrue(appender.warnings().get(1), appender.warnings().get(1).contains("unsigned_logoutrequest_accepted")); - - systemProperties.setProperty("saml.security.want_messages_signed", "true"); - authenticator.getSettings(); - - assertEquals(3, appender.warnings().size()); - assertFalse(appender.warnings().get(2), appender.warnings().get(2).contains("unsigned_logoutrequest_accepted")); - } finally { - systemProperties.remove("saml.idp.single_logout_service.url"); - systemProperties.remove("saml.security.want_messages_signed"); - appender.detach(); - } - } - - @Test - public void test_getSettings_propertyOverridesDefault() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - systemProperties.setProperty("saml.sp.entityid", "https://sp.example.com/metadata"); - systemProperties.setProperty("saml.security.want_assertions_signed", "true"); - - final Saml2Settings settings = authenticator.getSettings(); - - assertEquals("https://sp.example.com/metadata", settings.getSpEntityId()); - assertTrue(settings.getWantAssertionsSigned()); - } finally { - systemProperties.remove("saml.sp.entityid"); - systemProperties.remove("saml.security.want_assertions_signed"); - } - } - - @Test - public void test_getSettings_logsSecurityWarningsUntilTheyChange() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - authenticator.getSettings(); - authenticator.getSettings(); - - // the permissive defaults are reported, but only once - assertEquals(1, appender.warnings().size()); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("assertions_and_messages_not_required_signed")); - - systemProperties.setProperty("saml.security.want_assertions_signed", "true"); - authenticator.getSettings(); - - // the remaining warnings differ, so they are reported again - assertEquals(2, appender.warnings().size()); - assertFalse(appender.warnings().get(1).contains("assertions_and_messages_not_required_signed")); - } finally { - systemProperties.remove("saml.security.want_assertions_signed"); - appender.detach(); - } - } - - @Test - public void test_getSettings_sharesReplayCacheAcrossRequests() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // the properties have to change between the two calls, otherwise the settings cache - // hands back the same instance and comparing its replay cache with itself asserts - // nothing. What matters is that the cache survives a *rebuild*: it is the only thing - // that would otherwise be discarded, letting an assertion already seen be replayed - // whenever an administrator saves a SAML setting. - final Saml2Settings first = authenticator.getSettings(); - systemProperties.setProperty("saml.security.want_assertions_signed", "true"); - final Saml2Settings second = authenticator.getSettings(); - - Assertions.assertNotSame(first, second); - assertNotNull(first.getReplayCache()); - assertSame(first.getReplayCache(), second.getReplayCache()); - } finally { - systemProperties.remove("saml.security.want_assertions_signed"); - } - } - - @Test - public void test_getLogoutResponse_withoutIdpSingleLogoutServiceUrl() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - // a real logout message, so that the missing configuration is what fails - getMockRequest().setParameter("SAMLRequest", "PHNhbWxwOkxvZ291dFJlcXVlc3QgLz4="); - try { - authenticator.getResponse(SsoResponseType.LOGOUT); - fail("SsoMessageException should be thrown"); - } catch (final SsoMessageException e) { - assertNotNull(e.getCause()); - assertTrue(e.getCause().getMessage(), e.getCause().getMessage().contains("single logout service URL")); - } - } - - @Test - public void test_getLogoutResponse_withoutSamlLogoutMessageAndWithoutSlo() throws Exception { - // single logout is optional, so the anonymous visit below reaches a deployment that never - // configured it. It must still be rejected as a request rather than reported as a fault, - // or /sso/logout writes a stack trace per anonymous hit on every such deployment. - final SamlAuthenticator authenticator = createAuthenticator(); - try { - authenticator.getResponse(SsoResponseType.LOGOUT); - fail("SsoMessageException should be thrown"); - } catch (final SsoMessageException e) { - assertTrue(String.valueOf(e.getCause()), e.getCause() instanceof SsoStateException); - assertEquals("This endpoint expects a SAML logout message from the IdP.", e.getCause().getMessage()); - } - } - - // =================================================================================== - // Assertion Consumer Service - // ========================== - - /** Minimal IdP settings, so that an AuthnRequest can actually be built. */ - private void setUpIdp(final DynamicProperties systemProperties) { - systemProperties.setProperty("saml.idp.entityid", "https://idp.example.com/metadata"); - systemProperties.setProperty("saml.idp.single_sign_on_service.url", "https://idp.example.com/sso"); - systemProperties.setProperty("saml.idp.certfingerprint", "afe71c28ef740bc87425be13a2263d37971da1f9"); - } - - private void tearDownIdp(final DynamicProperties systemProperties) { - systemProperties.remove("saml.idp.entityid"); - systemProperties.remove("saml.idp.single_sign_on_service.url"); - systemProperties.remove("saml.idp.certfingerprint"); - } - - /** Lets a test move the clock that pending AuthnRequest ID expiry is measured against. */ - private final AtomicLong clock = new AtomicLong(1_000_000L); - - private SamlAuthenticator createAuthenticatorWithControlledClock() throws Exception { - ComponentUtil.register(new SystemHelper() { - @Override - public long getCurrentTimeAsLong() { - return clock.get(); - } - }, "systemHelper"); - return createAuthenticator(); - } - - /** - * Reads the pending AuthnRequest IDs out of the session without assuming the shape of the - * attribute, so that the same assertion can be run against the build that stored a single ID - * as a bare String. - */ - private Set pendingRequestIds(final HttpSession session) { - final Object value = session == null ? null : session.getAttribute("SAML_STATE"); - if (value instanceof final Map requestIdMap) { - return requestIdMap.keySet().stream().map(String::valueOf).collect(Collectors.toCollection(LinkedHashSet::new)); - } - if (value instanceof final String requestId) { - return new LinkedHashSet<>(List.of(requestId)); - } - return new LinkedHashSet<>(); - } - - /** - * Puts a SAML response on the request that is well formed enough to reach the InResponseTo - * comparison and is rejected right after it. It carries no signature, so it can never - * authenticate; what it makes observable is which pending AuthnRequest ID it was compared - * with, without a test having to sign an assertion. - * - * @param request The request the IdP is pretending to post to. - * @param inResponseTo The AuthnRequest ID the response claims to answer. - */ - private void postSamlResponse(final MockletHttpServletRequest request, final String inResponseTo) { - final String xml = "" - + "" - + "" - + "https://idp.example.com/metadata"; - request.setMethod("POST"); - request.setParameter("SAMLResponse", Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8))); - } - - @Test - public void test_containsSamlResponse() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - - assertFalse(authenticator.containsSamlResponse(getMockRequest())); - - final MockletHttpServletRequest blank = getMockRequest(); - blank.setParameter("SAMLResponse", " "); - assertFalse(authenticator.containsSamlResponse(blank)); - - final MockletHttpServletRequest posted = getMockRequest(); - posted.setParameter("SAMLResponse", "PHNhbWxwOlJlc3BvbnNlIC8+"); - assertTrue(authenticator.containsSamlResponse(posted)); - } - - @Test - public void test_getLoginCredential_unmatchedResponseFailsInsteadOfRedirecting() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - setUpIdp(systemProperties); - // the IdP posts the assertion cross-site, so a SameSite=Lax cookie is not sent back - // and the session holding the AuthnRequest ID is unreachable - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("POST"); - request.setParameter("SAMLResponse", "PHNhbWxwOlJlc3BvbnNlIC8+"); - - // redirecting to the IdP again would come straight back in the same state - assertNull(authenticator.getLoginCredential()); - assertEquals(1, appender.warnings().size()); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("no matching AuthnRequest ID")); - // there is no session at all, which is the one situation the cookie really explains, - // so this is where that guidance has to stay - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("tomcat.sameSiteCookies")); - } finally { - tearDownIdp(systemProperties); - appender.detach(); - } - } - - @Test - public void test_getLoginCredential_requestWithoutResponseStartsLogin() throws Exception { - // recording the AuthnRequest ID stamps it with SystemHelper's clock, which test_app.xml - // does not register on its own - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - - final LoginCredential credential = authenticator.getLoginCredential(); - - assertTrue(String.valueOf(credential), credential instanceof ActionResponseCredential); - assertNotNull(request.getSession(false).getAttribute("SAML_STATE")); - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_requestWithoutResponseKeepsPendingRequestIds() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - // FessSearchAction redirects every unauthenticated page hit to /sso/, so a session - // that expires with two tabs open sends two AuthnRequests. A single slot made the - // second visit abandon the first login, and the first assertion back then consumed - // the slot, failed the InResponseTo comparison and took both tabs down with it. - final MockletHttpServletRequest request = getMockRequest(); - - authenticator.getLoginCredential(); - final Set afterFirstVisit = pendingRequestIds(request.getSession(false)); - clock.addAndGet(1000L); - authenticator.getLoginCredential(); - final Set afterSecondVisit = pendingRequestIds(request.getSession(false)); - - assertEquals(1, afterFirstVisit.size(), String.valueOf(afterFirstVisit)); - assertEquals(2, afterSecondVisit.size(), String.valueOf(afterSecondVisit)); - assertTrue(String.valueOf(afterSecondVisit), afterSecondVisit.containsAll(afterFirstVisit)); - afterSecondVisit.forEach(requestId -> assertTrue(requestId, requestId.startsWith("ONELOGIN_"))); - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_requestWithoutResponseCarriesOverALegacyRequestId() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - // A session created before this change holds the single pending ID as a bare String. - // Casting it to the map would end the login with a ClassCastException, and dropping - // it would abandon a login that was already in flight over the upgrade. - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute("SAML_STATE", "ONELOGIN_legacy"); - - final LoginCredential credential = authenticator.getLoginCredential(); - - assertTrue(String.valueOf(credential), credential instanceof ActionResponseCredential); - final Set pending = pendingRequestIds(request.getSession(false)); - assertEquals(2, pending.size(), String.valueOf(pending)); - assertTrue(String.valueOf(pending), pending.contains("ONELOGIN_legacy")); - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getRequestIdMap_replacesAnUnusableSessionValue() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final HttpSession session = getMockRequest().getSession(); - session.setAttribute("SAML_STATE", Integer.valueOf(42)); - - final Map requestIdMap = authenticator.getRequestIdMap(session); - - // Anything that is not a live map and not a legacy ID is discarded rather than cast. - assertTrue(requestIdMap.toString(), requestIdMap.isEmpty()); - assertTrue(String.valueOf(requestIdMap), requestIdMap instanceof ConcurrentHashMap); - // The map that was handed out is the one stored back, so later writes are not lost. - assertSame(requestIdMap, session.getAttribute("SAML_STATE")); - } - - @Test - public void test_getLoginCredential_capsThePendingRequestIds() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - // /sso/ is anonymous and answers GET, so a page embedding it as a sub-resource would - // otherwise grow the session attribute for as long as the session lives. - final MockletHttpServletRequest request = getMockRequest(); - final List issuedRequestIds = new ArrayList<>(); - for (int i = 0; i < 12; i++) { - clock.addAndGet(1000L); - authenticator.getLoginCredential(); - pendingRequestIds(request.getSession(false)).stream() - .filter(requestId -> !issuedRequestIds.contains(requestId)) - .forEach(issuedRequestIds::add); - } - - final Set pending = pendingRequestIds(request.getSession(false)); - - assertEquals(12, issuedRequestIds.size(), String.valueOf(issuedRequestIds)); - assertEquals(10, pending.size(), String.valueOf(pending)); - // The two oldest were evicted; the most recent are the ones a user can still finish. - assertFalse(String.valueOf(pending), pending.contains(issuedRequestIds.get(0))); - assertFalse(String.valueOf(pending), pending.contains(issuedRequestIds.get(1))); - assertTrue(String.valueOf(pending), pending.contains(issuedRequestIds.get(2))); - assertTrue(String.valueOf(pending), pending.contains(issuedRequestIds.get(11))); - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_expiredRequestIdCannotBeAnswered() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - authenticator.getLoginCredential(); - final String requestId = pendingRequestIds(request.getSession(false)).iterator().next(); - - // attached only now, so that the insecure-settings warning the first getSettings() - // emits is not one of the messages asserted on below - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - // getRequestIdTtl() defaults to 3600 and is compared in seconds - clock.addAndGet(3601L * 1000L); - postSamlResponse(request, requestId); - - assertNull(authenticator.getLoginCredential()); - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - // The session was found and it did hold the ID until this very request pruned it, - // so the cookie demonstrably arrived. Reporting this as the SameSite case sends an - // operator whose cookie settings are already right off to change them, and this is - // the ordinary outcome of a user who walks away mid-login. - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("had expired")); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("all 1 pending")); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("saml.request.id.ttl")); - assertFalse(appender.warnings().get(0), appender.warnings().get(0).contains("tomcat.sameSiteCookies")); - assertTrue(pendingRequestIds(request.getSession(false)).toString(), pendingRequestIds(request.getSession(false)).isEmpty()); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_responseMatchingNoPendingRequestIdFails() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - authenticator.getLoginCredential(); - final Set pending = pendingRequestIds(request.getSession(false)); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - postSamlResponse(request, "ONELOGIN_never-sent"); - - // Answering it with a fresh AuthnRequest would bounce off an IdP that is already - // authenticated and come straight back here, forever. - assertNull(authenticator.getLoginCredential()); - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("no matching AuthnRequest ID")); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("(1 pending)")); - // A response nobody asked for must not be able to burn a login that is in flight: - // the assertion consumer service is reachable cross-site, since SAML needs - // SameSite=none, so consuming an ID here would be a denial of service per request. - Assertions.assertEquals(pending, pendingRequestIds(request.getSession(false))); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_reportsWhyTheResponseWasRefused() throws Exception { - // getErrors() answers a category: "invalid_response" covers a bad signature, an expired - // assertion, a foreign audience, a replay and a rewritten destination alike. On its own it - // tells an administrator only that the login failed, so the reason has to reach the log - // too -- and not only when saml.debug is on, which is off in every shipped configuration. - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - systemProperties.setProperty("saml.security.want_xml_validation", "false"); - assertNull(systemProperties.getProperty("saml.debug")); - final MockletHttpServletRequest request = getMockRequest(); - authenticator.getLoginCredential(); - final String requestId = pendingRequestIds(request.getSession(false)).iterator().next(); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - postSamlResponse(request, requestId); - assertNull(authenticator.getLoginCredential()); - - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - final String failure = appender.warnings().get(0); - // measured: "... - Reason: The Assertion must include a Conditions element", which - // is the part an administrator can act on. The wording belongs to java-saml, so the - // assertion is that a reason follows the category rather than what it says. - final String prefix = "Authentication Failure: invalid_response - Reason: "; - assertTrue(failure, failure.startsWith(prefix)); - assertTrue(failure, failure.length() > prefix.length()); - } finally { - appender.detach(); - } - } finally { - systemProperties.remove("saml.security.want_xml_validation"); - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_answersTheOlderOfTwoPendingRequestIds() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - // schema validation is not what this test is about, and switching it off keeps the - // response below down to the elements the InResponseTo comparison needs - systemProperties.setProperty("saml.security.want_xml_validation", "false"); - final MockletHttpServletRequest request = getMockRequest(); - authenticator.getLoginCredential(); - final String olderRequestId = pendingRequestIds(request.getSession(false)).iterator().next(); - clock.addAndGet(1000L); - authenticator.getLoginCredential(); - final Set pending = pendingRequestIds(request.getSession(false)); - assertEquals(2, pending.size(), String.valueOf(pending)); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - // the first tab's assertion comes back while the second tab's AuthnRequest is the - // most recent one, which is the case that used to fail both logins - postSamlResponse(request, olderRequestId); - assertNull(authenticator.getLoginCredential()); - - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - final String failure = appender.warnings().get(0); - // reaching the failure line at all means the loop went past the newest pending ID: - // a response matching none of them is reported by logUnmatchedSamlResponse instead - assertTrue(failure, failure.startsWith("Authentication Failure:")); - assertFalse(failure, failure.contains("no matching AuthnRequest ID")); - // and the reason reported is the older candidate's own rejection rather than the - // InResponseTo mismatch the newest candidate was ruled out on, which is what shows - // the response was tried against more than one. Read from our own log line rather - // than java-saml's, so that the test does not depend on the level that library - // reports a ruled-out candidate at. - assertTrue(failure, failure.contains("- Reason:")); - assertFalse(failure, failure.contains("does not match the ID of the AuthNRequest")); - assertFalse(failure, failure.contains(olderRequestId)); - } finally { - appender.detach(); - } - } finally { - systemProperties.remove("saml.security.want_xml_validation"); - tearDownIdp(systemProperties); - } - } - - /** - * Wraps an authenticator whose response processing always fails with {@code failure}, so that - * a test can choose the exception {@code getLoginCredential} has to classify. The instance is - * deliberately left without {@code defaultSettings}: the SAMLResponse branch reaches the - * override before anything asks for settings, so a test that needed them would be testing a - * different path than the one it names. - */ - private SamlAuthenticator failingAuthenticator(final RuntimeException failure) { - return new SamlAuthenticator() { - @Override - protected LoginCredential processSamlResponse(final HttpServletRequest request, final HttpServletResponse response, - final Map requestIdMap) { - throw failure; - } - }; - } - - /** - * Seeds a session with one pending AuthnRequest ID and puts {@code samlResponse} on the - * request, which is the state a malformed callback arrives in. - * - * @param authenticator The authenticator that sends the AuthnRequest. - * @param samlResponse The raw value of the SAMLResponse parameter. - * @return The request, now carrying both the session and the response. - */ - private MockletHttpServletRequest postRawSamlResponse(final SamlAuthenticator authenticator, final String samlResponse) { - final MockletHttpServletRequest request = getMockRequest(); - authenticator.getLoginCredential(); - request.setMethod("POST"); - request.setParameter("SAMLResponse", samlResponse); - return request; - } - - /** A SAMLResponse that is not base64 at all, the cheapest payload an anonymous client can send. */ - private static final String NOT_BASE64 = "!!! not base64 at all !!!"; - - /** A SAMLResponse that decodes cleanly and is then not parsable as XML. */ - private static final String BROKEN_XML = Base64.getEncoder().encodeToString(" warnEvents = appender.eventsAt(Level.WARN); - assertEquals(1, warnEvents.size(), String.valueOf(appender.warnings())); - assertEquals("Authentication failed: ValidationException: SAML Response could not be processed", - appender.warnings().get(0)); - assertNull(warnEvents.get(0).getThrown(), String.valueOf(warnEvents.get(0).getThrown())); - // the pending ID is not consumed by the failure, which is what makes the same - // request repeatable for the whole TTL and therefore worth not tracing - assertEquals(1, pendingRequestIds(request.getSession(false)).size()); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_brokenXmlResponseIsWarnedWithoutAStackTrace() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - // base64 that decodes fine and is then not XML reaches the same throw by a different - // route, so neither a stricter decoder nor a stricter parser alone closes this - postRawSamlResponse(authenticator, BROKEN_XML); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - assertNull(authenticator.getLoginCredential()); - - final List warnEvents = appender.eventsAt(Level.WARN); - assertEquals(1, warnEvents.size(), String.valueOf(appender.warnings())); - assertEquals("Authentication failed: ValidationException: SAML Response could not be processed", - appender.warnings().get(0)); - assertNull(warnEvents.get(0).getThrown(), String.valueOf(warnEvents.get(0).getThrown())); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_unparsableResponseKeepsTheStackTraceAtDebug() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - postRawSamlResponse(authenticator, NOT_BASE64); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - assertNull(authenticator.getLoginCredential()); - - // dropping the trace from the WARN must not drop it from the build: an operator - // chasing a real IdP problem raises the level and gets everything back - final List traced = appender.eventsAt(Level.DEBUG) - .stream() - .filter(e -> "Authentication failed.".equals(e.getMessage().getFormattedMessage())) - .toList(); - assertEquals(1, traced.size(), String.valueOf(appender.messagesAt(Level.DEBUG))); - assertTrue(String.valueOf(traced.get(0).getThrown()), traced.get(0).getThrown() instanceof ValidationException); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_unparsableResponseCanBeRepeatedWithThePendingRequestId() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = postRawSamlResponse(authenticator, NOT_BASE64); - final Set pending = pendingRequestIds(request.getSession(false)); - assertEquals(1, pending.size(), String.valueOf(pending)); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - assertNull(authenticator.getLoginCredential()); - assertNull(authenticator.getLoginCredential()); - assertNull(authenticator.getLoginCredential()); - - // this is the whole reason the trace has to go: the failure leaves the pending ID - // in place, so the very same anonymous request answers again and again - assertEquals(3, appender.warnings().size(), String.valueOf(appender.warnings())); - Assertions.assertEquals(pending, pendingRequestIds(request.getSession(false))); - appender.eventsAt(Level.WARN).forEach(e -> assertNull(e.getThrown(), e.getMessage().getFormattedMessage())); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_unparsableResponseWarningNamesTheNestedCause() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = postRawSamlResponse(authenticator, BROKEN_XML); - // java-saml's own wrapper for a parse failure, whose message names neither what - // failed nor where: dropping the cause chain here would leave the WARN useless - final RuntimeException failure = new XMLParsingException("Failed to load XML data.", - new IOException("Stream closed", new IllegalStateException("underlying detail"))); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - assertNull(failingAuthenticator(failure).getLoginCredential()); - - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - assertEquals("Authentication failed: XMLParsingException: Failed to load XML data." - + " <- IOException: Stream closed <- IllegalStateException: underlying detail", appender.warnings().get(0)); - } finally { - appender.detach(); - } - assertEquals(1, pendingRequestIds(request.getSession(false)).size()); - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLoginCredential_nonSamlFailureKeepsItsStackTrace() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - postRawSamlResponse(authenticator, BROKEN_XML); - // anything that is not a SAML failure is a bug in this server rather than a payload an - // anonymous client chose, so it keeps the trace it always had - final RuntimeException failure = new IllegalStateException("something this server got wrong"); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - assertNull(failingAuthenticator(failure).getLoginCredential()); - - final List warnEvents = appender.eventsAt(Level.WARN); - assertEquals(1, warnEvents.size(), String.valueOf(appender.warnings())); - assertEquals("Authentication failed.", appender.warnings().get(0)); - assertSame(failure, warnEvents.get(0).getThrown()); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_describeSamlFailure_stopsOnACyclicCauseChain() throws Exception { - final SAMLException first = new SAMLException("first"); - final SAMLException second = new SAMLException("second", first); - first.initCause(second); - - // a chain that points back at itself must end the rendering, not the request - assertEquals("SAMLException: first <- SAMLException: second", new SamlAuthenticator().describeSamlFailure(first)); - } - - @Test - public void test_getRequestIdTtl_defaultsToOneHourInSeconds() throws Exception { - // removeExpiredRequestIds compares (now - created) / 1000 against this value - assertEquals(3600L, new SamlAuthenticator().getRequestIdTtl()); - } - - @Test - public void test_getRequestIdTtl_fallsBackWhenTheConfiguredValueIsNotANumber() throws Exception { - // A typo in conf/system.properties must not fail every login with a - // NumberFormatException nobody can act on. - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", "one hour"); - try { - assertEquals(3600L, new SamlAuthenticator().getRequestIdTtl()); - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("saml.request.id.ttl")); - } finally { - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", ""); - appender.detach(); - } - } - - @Test - public void test_getRequestIdTtl_blankValueIsNotReportedAsInvalid() throws Exception { - // A blank property means "unset" everywhere else in this class, so it must not warn. - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", " "); - try { - assertEquals(3600L, new SamlAuthenticator().getRequestIdTtl()); - assertTrue(String.valueOf(appender.warnings()), appender.warnings().isEmpty()); - } finally { - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", ""); - appender.detach(); - } - } - - @Test - public void test_getRequestIdTtl_fallsBackWhenTheConfiguredValueIsNotPositive() throws Exception { - // Both values parse, so nothing fails here, but removeExpiredRequestIds compares - // (now - created) / 1000 against the result: 0 drops the AuthnRequest ID one second after - // the IdP was sent to, and -1 drops it at once, so every SAML login in the deployment - // fails. 0 is not a far-fetched thing to write, either: it reads as "no expiry". - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", "0"); - assertEquals(3600L, new SamlAuthenticator().getRequestIdTtl()); - - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", "-1"); - assertEquals(3600L, new SamlAuthenticator().getRequestIdTtl()); - - assertEquals(2, appender.warnings().size(), String.valueOf(appender.warnings())); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("saml.request.id.ttl")); - // the value that was configured, not only the default that replaced it - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains(": 0.")); - assertTrue(appender.warnings().get(1), appender.warnings().get(1).contains(": -1.")); - } finally { - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", ""); - appender.detach(); - } - } - - @Test - public void test_getRequestIdTtl_reportsANonPositiveValueDifferentlyFromANonNumericOne() throws Exception { - // The two mistakes need different corrections, and 0 is a number, so reporting it as - // invalid would send an administrator hunting for a typo that is not there. - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", "0"); - new SamlAuthenticator().getRequestIdTtl(); - - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", "one hour"); - new SamlAuthenticator().getRequestIdTtl(); - - assertEquals(2, appender.warnings().size(), String.valueOf(appender.warnings())); - final String nonPositive = appender.warnings().get(0); - final String nonNumeric = appender.warnings().get(1); - assertTrue(nonPositive, nonPositive.contains("positive")); - assertFalse(nonPositive, nonPositive.contains("Invalid")); - assertTrue(nonNumeric, nonNumeric.contains("Invalid")); - assertFalse(nonNumeric, nonNumeric.contains("positive")); - } finally { - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", ""); - appender.detach(); - } - } - - @Test - public void test_setMaxRequestIds_honoursAPositiveValue() throws Exception { - // fess_sso++.xml offers 10 as the line to uncomment, so that is what an untouched - // deployment has to be running with. - final SamlAuthenticator authenticator = new SamlAuthenticator(); - assertEquals(10, authenticator.maxRequestIds); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - authenticator.setMaxRequestIds(1); - assertEquals(1, authenticator.maxRequestIds); - - authenticator.setMaxRequestIds(50); - assertEquals(50, authenticator.maxRequestIds); - - assertTrue(String.valueOf(appender.warnings()), appender.warnings().isEmpty()); - } finally { - appender.detach(); - } - } - - @Test - public void test_setMaxRequestIds_fallsBackWhenTheValueIsNotPositive() throws Exception { - // Both values are applied without failing here, but getCandidateRequestIds hands the cap - // to limit(): 0 leaves processSamlResponse no candidate to try and -1 makes limit() throw, - // so either way every SAML login in the deployment fails. 0 is not a far-fetched thing to - // write, either: it reads as "no limit". - final SamlAuthenticator authenticator = new SamlAuthenticator(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - authenticator.setMaxRequestIds(0); - assertEquals(10, authenticator.maxRequestIds); - - authenticator.setMaxRequestIds(-1); - assertEquals(10, authenticator.maxRequestIds); - - assertEquals(2, appender.warnings().size(), String.valueOf(appender.warnings())); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("maxRequestIds")); - // the value that was configured, not only the default that replaced it - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains(": 0.")); - assertTrue(appender.warnings().get(1), appender.warnings().get(1).contains(": -1.")); - } finally { - appender.detach(); - } - } - - @Test - public void test_getCandidateRequestIds_isNotEmptiedByANonPositiveCap() throws Exception { - // The end the guard protects. Taken literally, the cap discards the pending ID the - // session does hold before processSamlResponse ever sees it, and the login is then - // reported as a session cookie that never arrived. - final Map requestIdMap = new ConcurrentHashMap<>(); - requestIdMap.put("_pending", 1000L); - final SamlAuthenticator authenticator = new SamlAuthenticator(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - authenticator.setMaxRequestIds(0); - Assertions.assertEquals(List.of("_pending"), authenticator.getCandidateRequestIds(requestIdMap)); - - // -1 does not merely return nothing: limit() throws, and processSamlResponse's caller - // logs that as "Authentication failed." with no hint of the cap. - authenticator.setMaxRequestIds(-1); - Assertions.assertEquals(List.of("_pending"), authenticator.getCandidateRequestIds(requestIdMap)); - } finally { - appender.detach(); - } - } - - @Test - public void test_getLoginCredential_nonPositiveRequestIdTtlStillAnswersAResponse() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - // Taken literally, this expires the AuthnRequest ID one second after the browser was - // sent to the IdP, which no round trip can beat: the deployment would answer every - // login attempt with a warning and no session would ever be created. - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", "0"); - final MockletHttpServletRequest request = getMockRequest(); - authenticator.getLoginCredential(); - final String requestId = pendingRequestIds(request.getSession(false)).iterator().next(); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - clock.addAndGet(1000L); - postSamlResponse(request, requestId); - - // The response is unsigned, so it cannot authenticate; what this pins is that it - // was compared with the pending ID at all rather than finding it already pruned. - assertNull(authenticator.getLoginCredential()); - final List warnings = appender.warnings(); - assertTrue(String.valueOf(warnings), warnings.stream().anyMatch(w -> w.startsWith("Authentication Failure:"))); - assertTrue(String.valueOf(warnings), warnings.stream().noneMatch(w -> w.contains("had expired"))); - assertTrue(String.valueOf(warnings), warnings.stream().noneMatch(w -> w.contains("no matching AuthnRequest ID"))); - // and a rejected response does not consume the ID, so the login is still live - final Set pending = pendingRequestIds(request.getSession(false)); - assertTrue(String.valueOf(pending), pending.contains(requestId)); - } finally { - appender.detach(); - } - } finally { - ComponentUtil.getFessConfig().setSystemProperty("saml.request.id.ttl", ""); - tearDownIdp(systemProperties); - } - } - - @Test - public void test_logUnmatchedSamlResponse_separatesAnExpiredLoginFromAMissingSessionCookie() throws Exception { - // Both situations reach the log with nothing pending, so the text is the only thing that - // can tell them apart, and only one of them is a misconfiguration. - final SamlAuthenticator authenticator = new SamlAuthenticator(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - authenticator.logUnmatchedSamlResponse(0); - authenticator.logUnmatchedSamlResponseAfterExpiry(1); - - assertEquals(2, appender.warnings().size(), String.valueOf(appender.warnings())); - final String missingCookie = appender.warnings().get(0); - final String expired = appender.warnings().get(1); - assertTrue(missingCookie, missingCookie.contains("tomcat.sameSiteCookies")); - assertFalse(missingCookie, missingCookie.contains("saml.request.id.ttl")); - assertTrue(expired, expired.contains("saml.request.id.ttl")); - assertFalse(expired, expired.contains("tomcat.sameSiteCookies")); - } finally { - appender.detach(); - } - } - - @Test - public void test_getLoginCredential_duplicatedAttributeNameNamesTheSettingThatAcceptsIt() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = postRawSamlResponse(authenticator, BROKEN_XML); - // what an IdP that emits one element per value produces; the library - // raises it from getAttributes(), after the assertion has already validated - final RuntimeException failure = new ValidationException("Found an Attribute element with duplicated Name", - ValidationException.DUPLICATED_ATTRIBUTE_NAME_FOUND); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - assertNull(failingAuthenticator(failure).getLoginCredential()); - - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - final String warning = appender.warnings().get(0); - // the setting is the whole point of the line: without it an administrator is told - // a fact about the XML and has nothing to change - assertTrue(warning, warning.contains("saml.security.allow_duplicated_attribute_name")); - // and it must not be mistaken for the two cookie/timing diagnoses - assertFalse(warning, warning.contains("tomcat.sameSiteCookies")); - assertFalse(warning, warning.contains("saml.request.id.ttl")); - appender.eventsAt(Level.WARN).forEach(e -> assertNull(e.getThrown(), e.getMessage().getFormattedMessage())); - } finally { - appender.detach(); - } - // the ID is still there, so the login works as soon as either side is reconfigured - assertEquals(1, pendingRequestIds(request.getSession(false)).size()); - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_isDuplicatedAttributeName_matchesOnlyThatErrorCode() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - - assertTrue(authenticator - .isDuplicatedAttributeName(new ValidationException("duplicated", ValidationException.DUPLICATED_ATTRIBUTE_NAME_FOUND))); - // the code a retried candidate produces, which processSamlResponse handles on its own - assertFalse(authenticator.isDuplicatedAttributeName(new ValidationException("mismatch", ValidationException.WRONG_INRESPONSETO))); - assertFalse(authenticator.isDuplicatedAttributeName(new SAMLException("not a validation failure"))); - } - - @Test - public void test_hasExpiredSession_needsASessionIdThatIsNoLongerValid() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - // one request, walked through the three states, because getMockRequest() hands out the - // same instance for the whole test method - final MockletHttpServletRequest request = getMockRequest(); - - // a browser that is not sending the cookie sends no session id, so nothing was lost - assertNull(request.getRequestedSessionId()); - assertFalse(authenticator.hasExpiredSession(request)); - - // an id that came back with nothing behind it is the case worth naming - request.addCookie(new Cookie("jsessionid", "AB1C2D3E4F5061728394A5B6C7D8E9F0")); - assertNotNull(request.getRequestedSessionId()); - assertFalse(request.isRequestedSessionIdValid()); - assertTrue(authenticator.hasExpiredSession(request)); - - // an id the container still knows is a live session, not an expired one - request.getSession(); - assertTrue(request.isRequestedSessionIdValid()); - assertFalse(authenticator.hasExpiredSession(request)); - } - - @Test - public void test_getLoginCredential_responseWithoutASessionIdBlamesTheCookie() throws Exception { - // Nothing came back at all, which really is what a SameSite=Lax cookie on a cross-site - // POST looks like, so this is the one case that keeps that guidance. - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("POST"); - request.setParameter("SAMLResponse", "PHNhbWxwOlJlc3BvbnNlIC8+"); - assertNull(request.getRequestedSessionId()); - - assertNull(authenticator.getLoginCredential()); - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - final String warning = appender.warnings().get(0); - assertTrue(warning, warning.contains("tomcat.sameSiteCookies")); - assertFalse(warning, warning.contains("session it belongs to had expired")); - } finally { - tearDownIdp(systemProperties); - appender.detach(); - } - } - - @Test - public void test_getLoginCredential_expiredSessionIsNotReportedAsABlockedCookie() throws Exception { - // The realistic "walked away at the IdP" case arrives exactly like this, because the - // container reaps the session (30 minutes by default) long before saml.request.id.ttl - // (3600 seconds) can prune an ID, so the AuthnRequest IDs go with it and the branch that - // counts pruned IDs is never reached. Reported as the SameSite case it sends an operator - // whose cookie settings are already right off to change them. - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - request.setMethod("POST"); - request.setParameter("SAMLResponse", "PHNhbWxwOlJlc3BvbnNlIC8+"); - request.addCookie(new Cookie("jsessionid", "AB1C2D3E4F5061728394A5B6C7D8E9F0")); - // the cookie demonstrably arrived; there is simply no session behind it any more - assertNotNull(request.getRequestedSessionId()); - assertNull(request.getSession(false)); - - // still refused: bouncing back to an IdP that is already authenticated would only - // post the same unmatched assertion straight back - assertNull(authenticator.getLoginCredential()); - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - final String warning = appender.warnings().get(0); - assertTrue(warning, warning.contains("session it belongs to had expired")); - assertFalse(warning, warning.contains("tomcat.sameSiteCookies")); - // raising the TTL cannot extend a session that is already the shorter of the two - assertTrue(warning, warning.contains("raising that value does not help")); - } finally { - tearDownIdp(systemProperties); - appender.detach(); - } - } - - @Test - public void test_getLoginCredential_prunedRequestIdsStillNameTheTtl() throws Exception { - // The third case: the session is alive and its cookie is valid, and only the IDs it held - // ran out of time. That one really is about saml.request.id.ttl, so the session-expiry - // wording must not take it over. - final SamlAuthenticator authenticator = createAuthenticatorWithControlledClock(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - request.addCookie(new Cookie("jsessionid", "AB1C2D3E4F5061728394A5B6C7D8E9F0")); - authenticator.getLoginCredential(); - final String requestId = pendingRequestIds(request.getSession(false)).iterator().next(); - assertTrue(request.isRequestedSessionIdValid()); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - clock.addAndGet(3601L * 1000L); - postSamlResponse(request, requestId); - - assertNull(authenticator.getLoginCredential()); - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - final String warning = appender.warnings().get(0); - assertTrue(warning, warning.contains("all 1 pending AuthnRequest ID(s) of the session had expired")); - assertFalse(warning, warning.contains("session it belongs to had expired")); - assertFalse(warning, warning.contains("tomcat.sameSiteCookies")); - } finally { - appender.detach(); - } - } finally { - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLogoutResponse_withoutSamlLogoutMessage() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - systemProperties.setProperty("saml.idp.single_logout_service.url", "https://idp.example.com/slo"); - - // /sso/logout is anonymous, so it also receives plain visits carrying no SAML message - authenticator.getResponse(SsoResponseType.LOGOUT); - fail("SsoMessageException should be thrown"); - } catch (final SsoMessageException e) { - // a rejected request, not a fault: the cause decides that SsoAction logs it without a - // stack trace, and the user-facing text is ours rather than the library's binding note - assertTrue(String.valueOf(e.getCause()), e.getCause() instanceof SsoStateException); - assertEquals("This endpoint expects a SAML logout message from the IdP.", e.getCause().getMessage()); - } finally { - systemProperties.remove("saml.idp.single_logout_service.url"); - tearDownIdp(systemProperties); - } - } - - @Test - public void test_getLogoutResponse_rejectsAnUnusableLogoutMessageWithoutAStackTrace() throws Exception { - // The message here is one the sender supplied and java-saml refused. /sso/logout is - // anonymous and, because SAML requires SameSite=none, reachable cross-site, so an - // unauthenticated client can repeat this at will; a stack trace per attempt would let it - // fill the log. The cause is what SsoAction branches on to log the message alone. - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpIdp(systemProperties); - systemProperties.setProperty("saml.idp.single_logout_service.url", "https://idp.example.com/slo"); - final MockletHttpServletRequest request = getMockRequest(); - // a LogoutRequest with no NameID: java-saml parses it and then refuses it - final String xml = ""; - request.setParameter("SAMLRequest", Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8))); - - authenticator.getResponse(SsoResponseType.LOGOUT); - fail("SsoMessageException should be thrown"); - } catch (final SsoMessageException e) { - assertTrue(String.valueOf(e.getCause()), e.getCause() instanceof SsoStateException); - } finally { - systemProperties.remove("saml.idp.single_logout_service.url"); - tearDownIdp(systemProperties); - } - } - - @Test - public void test_containsSamlLogoutMessage() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - - assertFalse(authenticator.containsSamlLogoutMessage(getMockRequest())); - - final MockletHttpServletRequest blank = getMockRequest(); - blank.setParameter("SAMLRequest", " "); - assertFalse(authenticator.containsSamlLogoutMessage(blank)); - - final MockletHttpServletRequest logoutRequest = getMockRequest(); - logoutRequest.setParameter("SAMLRequest", "PHNhbWxwOkxvZ291dFJlcXVlc3QgLz4="); - assertTrue(authenticator.containsSamlLogoutMessage(logoutRequest)); - - final MockletHttpServletRequest logoutResponse = getMockRequest(); - logoutResponse.setParameter("SAMLResponse", "PHNhbWxwOkxvZ291dFJlc3BvbnNlIC8+"); - assertTrue(authenticator.containsSamlLogoutMessage(logoutResponse)); - } - - @Test - public void test_getMetadataResponse_withoutIdpSettings() throws Exception { - // the SP metadata is what the IdP is registered from, so it must not require saml.idp.* - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - systemProperties.setProperty(BASE_URL_KEY, "https://fess.example.com"); - - final ActionResponse response = authenticator.getResponse(SsoResponseType.METADATA); - - assertTrue(String.valueOf(response), response instanceof StreamResponse); - assertEquals("metadata.xml", ((StreamResponse) response).getFileName()); - } finally { - systemProperties.remove(BASE_URL_KEY); - } - } - - @Test - public void test_getMetadataResponse_reportsInvalidSpSettings() throws Exception { - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // an SP entity ID that is not a URL leaves the ACS URL unset - systemProperties.setProperty("saml.sp.assertion_consumer_service.url", "not a url"); - - authenticator.getResponse(SsoResponseType.METADATA); - fail("SsoMessageException should be thrown"); - } catch (final SsoMessageException e) { - assertNotNull(e.getCause()); - assertTrue(e.getCause().getMessage(), e.getCause().getMessage().contains("sp_acs_not_found")); - } finally { - systemProperties.remove("saml.sp.assertion_consumer_service.url"); - } - } - - // =================================================================================== - // Single Logout Service - // ===================== - - /** The session attribute a test watches to tell an invalidated session from a kept one. */ - private static final String SESSION_MARKER = "SLO_TEST_MARKER"; - - /** IdP settings that also make the single logout service reachable. */ - private void setUpSlo(final DynamicProperties systemProperties) { - setUpIdp(systemProperties); - systemProperties.setProperty("saml.idp.single_logout_service.url", "https://idp.example.com/slo"); - } - - private void tearDownSlo(final DynamicProperties systemProperties) { - systemProperties.remove("saml.idp.single_logout_service.url"); - tearDownIdp(systemProperties); - } - - /** - * Builds an authenticator that reports the given user as logged in. The real - * {@code FessLoginAssist} cannot be resolved here because it injects the user index, which is - * exactly why reading the session user sits behind an overridable method. - */ - private SamlAuthenticator createAuthenticatorLoggedInAs(final OptionalThing userBean) throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator() { - @Override - protected OptionalThing getSavedUserBean() { - return userBean; - } - }; - final Field field = SamlAuthenticator.class.getDeclaredField("defaultSettings"); - field.setAccessible(true); - field.set(authenticator, authenticator.createDefaultSettings()); - return authenticator; - } - - /** The session bean a SAML login leaves behind; SamlUser.getName() is the NameID. */ - private OptionalThing samlUserBean(final String nameId) { - return OptionalThing.of(new FessUserBean(new SamlUser(nameId, "_sessionIndex", null, null, null, new String[0], new String[0]))); - } - - /** - * Puts an IdP-initiated LogoutRequest on the request, shaped the way one that nobody - * authenticated looks: no signature, and neither {@code NotOnOrAfter} nor {@code Destination}, - * both of which java-saml checks only when the attribute is present. - * - * @param request The request the IdP is pretending to send. - * @param id The LogoutRequest ID, which the replay cache keys on. - * @param nameId The NameID the LogoutRequest asks to log out. - */ - private void sendLogoutRequest(final MockletHttpServletRequest request, final String id, final String nameId) { - final String xml = "" + "https://idp.example.com/metadata" - + "" + nameId + ""; - request.setParameter("SAMLRequest", Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8))); - } - - @Test - public void test_getLogoutResponse_keepsTheSessionWhenTheLogoutRequestNamesAnotherUser() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorLoggedInAs(samlUserBean("victim@example.com")); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - // /sso/logout is anonymous and SAML forces SameSite=none, so this request reaches the - // endpoint cross-site with the victim's session cookie on it - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(SESSION_MARKER, "kept"); - sendLogoutRequest(request, "_crafted", "attacker@example.com"); - // primed so that the insecure-settings warning is not one of the lines asserted below - authenticator.getSettings(); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - final ActionResponse response = authenticator.getResponse(SsoResponseType.LOGOUT); - - assertEquals("kept", request.getSession(false).getAttribute(SESSION_MARKER)); - // the IdP still gets an ordinary LogoutResponse: an error would tell the sender - // whether it guessed a live session, and would strand a confused-but-real IdP - assertTrue(String.valueOf(response), String.valueOf(response).contains("https://idp.example.com/slo?SAMLResponse=")); - assertEquals(1, appender.warnings().size(), String.valueOf(appender.warnings())); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("attacker@example.com")); - assertTrue(appender.warnings().get(0), appender.warnings().get(0).contains("victim@example.com")); - } finally { - appender.detach(); - } - } finally { - tearDownSlo(systemProperties); - } - } - - @Test - public void test_getLogoutResponse_endsTheSessionWhenTheLogoutRequestNamesTheSessionUser() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorLoggedInAs(samlUserBean("victim@example.com")); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(SESSION_MARKER, "kept"); - sendLogoutRequest(request, "_slo", "victim@example.com"); - authenticator.getSettings(); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - final ActionResponse response = authenticator.getResponse(SsoResponseType.LOGOUT); - - // the whole point of single logout: the IdP says so, so the session ends - assertNull(request.getSession(false).getAttribute(SESSION_MARKER), "the session must not survive its own logout"); - assertTrue(String.valueOf(response), String.valueOf(response).contains("https://idp.example.com/slo?SAMLResponse=")); - assertTrue(String.valueOf(appender.warnings()), appender.warnings().isEmpty()); - } finally { - appender.detach(); - } - } finally { - tearDownSlo(systemProperties); - } - } - - @Test - public void test_getLogoutResponse_endsTheSessionWhenTheNameIdDiffersOnlyInFormatting() throws Exception { - // The two NameIDs are read from the text content of two different XML documents, and - // java-saml trims neither unless saml.parsing.trim_name_ids is turned on, which Fess - // leaves off. An IdP that pretty-prints its LogoutRequest but not its assertion, or that - // normalises the case of a UPN in one and not the other, must not end up unable to log - // anyone out -- that failure is silent and looks like the session refusing to die. - final SamlAuthenticator authenticator = createAuthenticatorLoggedInAs(samlUserBean("Victim@Example.com")); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(SESSION_MARKER, "kept"); - sendLogoutRequest(request, "_formatted", "\n victim@example.com\n "); - authenticator.getSettings(); - - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - authenticator.getResponse(SsoResponseType.LOGOUT); - - assertNull(request.getSession(false).getAttribute(SESSION_MARKER), "a reformatted NameID is still the same user"); - assertTrue(String.valueOf(appender.warnings()), appender.warnings().isEmpty()); - } finally { - appender.detach(); - } - } finally { - tearDownSlo(systemProperties); - } - } - - @Test - public void test_getLogoutResponse_endsTheSessionWhenNobodyIsLoggedIn() throws Exception { - // With no session user there is no NameID to compare against, so the LogoutRequest keeps - // the effect it always had rather than being refused on a comparison that cannot be made. - final SamlAuthenticator authenticator = createAuthenticatorLoggedInAs(OptionalThing.empty()); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(SESSION_MARKER, "kept"); - sendLogoutRequest(request, "_anonymous", "someone@example.com"); - - final ActionResponse response = authenticator.getResponse(SsoResponseType.LOGOUT); - - assertNull(request.getSession(false).getAttribute(SESSION_MARKER), "the session must still be invalidated"); - assertTrue(String.valueOf(response), String.valueOf(response).contains("https://idp.example.com/slo?SAMLResponse=")); - } finally { - tearDownSlo(systemProperties); - } - } - - @Test - public void test_getLogoutResponse_keepsTheSessionWhenTheLogoutRequestNamesNobody() throws Exception { - // java-saml insists on the element being there but never on it carrying - // anything, so an empty one parses and names nobody. Treating that as "cannot tell" would - // hand back the whole bypass: the sender picks the value, and an empty element costs it - // nothing, so every session would be one crafted URL away from ending again. - for (final String nameId : new String[] { "", " ", "\n" }) { - final SamlAuthenticator authenticator = createAuthenticatorLoggedInAs(samlUserBean("victim@example.com")); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - final MockletHttpServletRequest request = getMockRequest(); - request.getSession().setAttribute(SESSION_MARKER, "kept"); - sendLogoutRequest(request, "_empty" + nameId.length(), nameId); - authenticator.getSettings(); - - final ActionResponse response = authenticator.getResponse(SsoResponseType.LOGOUT); - - assertEquals("a LogoutRequest that names nobody must not end a session", "kept", - request.getSession(false).getAttribute(SESSION_MARKER)); - // the IdP still gets an ordinary LogoutResponse: an error would tell an - // unauthenticated sender whether it guessed a live session - assertTrue(String.valueOf(response), String.valueOf(response).contains("https://idp.example.com/slo?SAMLResponse=")); - } finally { - tearDownSlo(systemProperties); - } - } - } - - @Test - public void test_getLogoutRequestNameId_tellsAnEmptyNameIdApartFromAnUnreadableOne() throws Exception { - // isLogoutRequestForAnotherUser branches on null, not on blank, so the two have to stay - // distinguishable here: null means java-saml is about to fail on the same bytes, while "" - // means the message parsed and simply named nobody. - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - final Saml2Settings settings = authenticator.getSettings(); - - final MockletHttpServletRequest empty = getMockRequest(); - sendLogoutRequest(empty, "_emptyname", ""); - assertEquals("", authenticator.getLogoutRequestNameId(empty, settings)); - - final MockletHttpServletRequest notXml = getMockRequest(); - notXml.setParameter("SAMLRequest", "................"); - assertNull(authenticator.getLogoutRequestNameId(notXml, settings)); - } finally { - tearDownSlo(systemProperties); - } - } - - @Test - public void test_isLogoutRequestForAnotherUser_leavesALogoutResponseAlone() throws Exception { - final SamlAuthenticator authenticator = createAuthenticatorLoggedInAs(samlUserBean("victim@example.com")); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - // the IdP answering a LogoutRequest this SP sent. Handing such a request to the - // LogoutRequest parser would not parse anything: with no SAMLRequest parameter it - // builds a fresh outgoing message instead, whose NameID defaults to the IdP entity ID - // and therefore never matches the session user, leaving every SP-initiated logout with - // a session that refuses to end. - final MockletHttpServletRequest request = getMockRequest(); - request.setParameter("SAMLResponse", "PHNhbWxwOkxvZ291dFJlc3BvbnNlIC8+"); - - assertFalse(authenticator.isLogoutRequestForAnotherUser(request, authenticator.getSettings())); - } finally { - tearDownSlo(systemProperties); - } - } - - @Test - public void test_getLogoutRequestNameId_returnsNullWhenTheNameIdCannotBeRead() throws Exception { - // A NameID this check cannot read must mean "cannot tell", which leaves the previous - // behaviour in place; throwing here would turn an unreadable message into a broken logout. - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - setUpSlo(systemProperties); - final Saml2Settings settings = authenticator.getSettings(); - - final MockletHttpServletRequest wellFormed = getMockRequest(); - sendLogoutRequest(wellFormed, "_readable", "victim@example.com"); - assertEquals("victim@example.com", authenticator.getLogoutRequestNameId(wellFormed, settings)); - - final MockletHttpServletRequest notXml = getMockRequest(); - notXml.setParameter("SAMLRequest", "................"); - assertNull(authenticator.getLogoutRequestNameId(notXml, settings), "an undecodable message has no NameID"); - - final MockletHttpServletRequest withoutNameId = getMockRequest(); - final String xml = ""; - withoutNameId.setParameter("SAMLRequest", Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8))); - assertNull(authenticator.getLogoutRequestNameId(withoutNameId, settings), "java-saml rejects this one on its own later"); - } finally { - tearDownSlo(systemProperties); - } - } - - @Test - public void test_getLogoutRequestNameId_parsesTheMessageOnce() throws Exception { - // A message that does not parse is not free to look at: java-saml answers null and logs - // the failure with its stack trace, so every parse of one writes about ninety lines. The - // endpoint is anonymous and, because SAML requires SameSite=none, reachable cross-site - // with a victim's cookie attached, which is the only case in which this method runs at - // all -- so a second parse would land on precisely the sessions an attacker aims at. - final SamlAuthenticator authenticator = createAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - final LogCapturingAppender library = LogCapturingAppender.attach("org.codelibs.saml2.core.util.Util"); - try { - setUpSlo(systemProperties); - final Saml2Settings settings = authenticator.getSettings(); - - final MockletHttpServletRequest notXml = getMockRequest(); - notXml.setParameter("SAMLRequest", "................"); - - assertNull(authenticator.getLogoutRequestNameId(notXml, settings)); - // one failed parse, one warning: reaching the XML through a LogoutRequest instead - // parses it a second time and doubles this - assertEquals(1, library.warnings().size()); - } finally { - library.detach(); - tearDownSlo(systemProperties); - } - } - - @Test - public void test_getSessionSamlNameId_ignoresAUserThatDidNotComeFromSaml() throws Exception { - // A local or LDAP login carries a user name, not a NameID, and comparing the two would - // reject every legitimate single logout on a mixed-authentication deployment. - assertEquals("victim@example.com", createAuthenticatorLoggedInAs(samlUserBean("victim@example.com")).getSessionSamlNameId()); - assertNull(createAuthenticatorLoggedInAs(OptionalThing.of(new FessUserBean(new LocalUser("victim@example.com")))) - .getSessionSamlNameId(), "a non-SAML user has no NameID to compare"); - assertNull(createAuthenticatorLoggedInAs(OptionalThing.empty()).getSessionSamlNameId(), "nobody is logged in"); - } - - @Test - public void test_isSameNameId_toleratesFormattingButNotADifferentUser() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - - // the two sides come from different XML documents, which the IdP may format differently - assertTrue(authenticator.isSameNameId("victim@example.com", " victim@example.com\n")); - // an IdP that normalises the case of a UPN in one message and not the other is a real - // deployment; a sender that does not know the NameID fails whatever case it picks - assertTrue(authenticator.isSameNameId("Victim@Example.com", "victim@example.com")); - assertFalse(authenticator.isSameNameId("victim@example.com", "attacker@example.com")); - // a NameID that names nobody is not the session user either, whatever it is padded with - assertFalse(authenticator.isSameNameId("victim@example.com", "")); - assertFalse(authenticator.isSameNameId("victim@example.com", " ")); - assertFalse(authenticator.isSameNameId("victim@example.com", "\n")); - } - - @Test - public void test_sanitizeForLog_keepsAnUnauthenticatedNameIdFromForgingLogLines() throws Exception { - // the NameID of the LogoutRequest is written to the log before anything has authenticated - // the message, and it is XML text content, so it can carry a line break - assertEquals("victim@example.com? ERROR forged", SamlAuthenticator.sanitizeForLog("victim@example.com\n ERROR forged")); - assertEquals("victim@example.com? ERROR forged", SamlAuthenticator.sanitizeForLog("victim@example.com\r ERROR forged")); - // \p{Cntrl} is ASCII-only, so the Unicode break characters a log viewer still renders as - // a new line have to be covered separately - assertEquals("a?b?c", SamlAuthenticator.sanitizeForLog("a\u0085b\u2028c")); - - final int max = SamlAuthenticator.MAX_LOGGED_NAME_ID_LENGTH; - assertEquals("x".repeat(max) + "...", SamlAuthenticator.sanitizeForLog("x".repeat(max + 10))); - // an ordinary NameID is passed through untouched - assertEquals("victim@example.com", SamlAuthenticator.sanitizeForLog("victim@example.com")); - - // a rejection reason quotes the message it objected to, so it is sender-supplied in the - // same way and gets the same treatment at a bound that leaves the sentence readable - final int reasonMax = SamlAuthenticator.MAX_LOGGED_FAILURE_REASON_LENGTH; - assertTrue(String.valueOf(reasonMax), reasonMax > max); - assertEquals("Invalid issuer in the Assertion/Response. Was '?ERROR forged'", - SamlAuthenticator.sanitizeForLog("Invalid issuer in the Assertion/Response. Was '\nERROR forged'", reasonMax)); - assertEquals("y".repeat(reasonMax) + "...", SamlAuthenticator.sanitizeForLog("y".repeat(reasonMax + 1), reasonMax)); - } - - @Test - public void test_buildDefaultUrl_withDefaultBaseUrl() throws Exception { - assertEquals("http://localhost:8080/sso/metadata", new SamlAuthenticator().buildDefaultUrl("/sso/metadata")); - } - - @Test - public void test_buildDefaultUrl_withCustomBaseUrl() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - systemProperties.setProperty(BASE_URL_KEY, "https://fess.example.com:8443"); - - assertEquals("https://fess.example.com:8443/sso/metadata", authenticator.buildDefaultUrl("/sso/metadata")); - } finally { - systemProperties.remove(BASE_URL_KEY); - } - } - - @Test - public void test_buildDefaultUrl_withTrailingSlash() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - systemProperties.setProperty(BASE_URL_KEY, "https://fess.example.com/"); - - assertEquals("https://fess.example.com/sso/", authenticator.buildDefaultUrl("/sso/")); - } finally { - systemProperties.remove(BASE_URL_KEY); - } - } - - @Test - public void test_buildDefaultUrl_withBlankProperty() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - systemProperties.setProperty(BASE_URL_KEY, " "); - - assertEquals("http://localhost:8080/sso/logout", authenticator.buildDefaultUrl("/sso/logout")); - } finally { - systemProperties.remove(BASE_URL_KEY); - } - } - - @Test - public void test_isLogoutResponse() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator(); - // getMockRequest() hands out the one request of this test, so each step overwrites the - // parameter the previous one set rather than starting from a clean request. - final MockletHttpServletRequest request = getMockRequest(); - - // No logout message at all. - assertFalse(authenticator.isLogoutResponse(request)); - - request.setParameter("SAMLResponse", " "); - assertFalse(authenticator.isLogoutResponse(request)); - - request.setParameter("SAMLResponse", "PHNhbWxwOkxvZ291dFJlc3BvbnNlIC8+"); - assertTrue(authenticator.isLogoutResponse(request)); - - // Auth#processSLO reads SAMLResponse first, but a request carrying both has to be treated - // as the LogoutRequest it also is: the NameID comparison is what guards that branch, and - // an IdP-initiated logout does end the session. - request.setParameter("SAMLRequest", "PHNhbWxwOkxvZ291dFJlcXVlc3QgLz4="); - assertFalse(authenticator.isLogoutResponse(request)); - - request.setParameter("SAMLRequest", ""); - assertTrue(authenticator.isLogoutResponse(request)); - } - - @Test - public void test_warnIfLogoutResponseReachedALiveLogin_reportsOnlyTheLoginThatIsStillLive() throws Exception { - final boolean[] loggedIn = { true }; - final SamlAuthenticator authenticator = new SamlAuthenticator() { - @Override - protected boolean isLoggedIn() { - return loggedIn[0]; - } - }; - final MockletHttpServletRequest request = getMockRequest(); - final LogCapturingAppender appender = LogCapturingAppender.attach(SamlAuthenticator.class); - try { - // A LogoutRequest is the other branch, reported by its own NameID comparison. - request.setParameter("SAMLRequest", "PHNhbWxwOkxvZ291dFJlcXVlc3QgLz4="); - authenticator.warnIfLogoutResponseReachedALiveLogin(request); - assertEquals(String.valueOf(appender.warnings()), 0, appender.warnings().size()); - - // The legitimate answer arrives after LogoutAction has ended the login, so the path a - // logout actually takes stays silent. - request.setParameter("SAMLRequest", ""); - request.setParameter("SAMLResponse", "PHNhbWxwOkxvZ291dFJlc3BvbnNlIC8+"); - loggedIn[0] = false; - authenticator.warnIfLogoutResponseReachedALiveLogin(request); - assertEquals(String.valueOf(appender.warnings()), 0, appender.warnings().size()); - - // A LogoutResponse aimed at a live login answers a logout that was never started. - loggedIn[0] = true; - authenticator.warnIfLogoutResponseReachedALiveLogin(request); - - final List warnings = appender.warnings(); - assertEquals(String.valueOf(warnings), 1, warnings.size()); - final String message = warnings.get(0); - assertTrue(message, message.contains("LogoutResponse")); - assertTrue(message, message.contains("still logged in")); - // The endpoint is anonymous and nothing in the message was validated: one bounded - // line, no stack trace, and nothing the sender supplied. - assertFalse(message, message.contains("PHNhbWxwOkxvZ291dFJlc3BvbnNlIC8+")); - assertEquals(String.valueOf(appender.errors()), 0, appender.errors().size()); - } finally { - appender.detach(); - } - } - - @Test - public void test_isLoggedIn_answersFalseWhenTheSessionCannotBeRead() throws Exception { - final SamlAuthenticator authenticator = new SamlAuthenticator() { - @Override - protected OptionalThing getSavedUserBean() { - throw new IllegalStateException("no login scope"); - } - }; - - // /sso/logout has to keep working for a request that reaches it outside a login scope, so - // "cannot tell" must not become "fail" -- and must not become "logged in" either, which - // would report every legitimate logout. - assertFalse(authenticator.isLoggedIn()); - } - - /** A user that did not authenticate through SAML, as a local or LDAP login leaves behind. */ - private static class LocalUser implements FessUser { - - private static final long serialVersionUID = 1L; - - private final String name; - - LocalUser(final String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - - @Override - public String[] getRoleNames() { - return new String[0]; - } - - @Override - public String[] getGroupNames() { - return new String[0]; - } - - @Override - public String[] getPermissions() { - return new String[0]; - } - } -} diff --git a/src/test/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticatorTest.java b/src/test/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticatorTest.java deleted file mode 100644 index b6742e0e9f..0000000000 --- a/src/test/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticatorTest.java +++ /dev/null @@ -1,643 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.spnego; - -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -import org.codelibs.core.misc.DynamicProperties; -import org.codelibs.fess.exception.SsoLoginException; -import org.codelibs.fess.exception.SsoStateException; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.spnego.SpnegoHttpFilter.Constants; -import org.codelibs.spnego.SpnegoProvider; -import org.ietf.jgss.GSSException; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; - -import jakarta.servlet.http.HttpServletRequest; - -public class SpnegoAuthenticatorTest extends UnitFessTestCase { - - @Override - protected void setUp(TestInfo testInfo) throws Exception { - super.setUp(testInfo); - } - - @Override - protected void tearDown(TestInfo testInfo) throws Exception { - // Ensure spnego.* system properties possibly set by a test do not leak. - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - systemProperties.remove("spnego.logger.level"); - systemProperties.remove("spnego.allowed.realms"); - systemProperties.remove("spnego.login.client.module"); - systemProperties.remove("spnego.exclude.dirs"); - super.tearDown(testInfo); - } - - /** Builds a request stub that answers only getHeader(), which is all the check reads. */ - private HttpServletRequest requestWithAuthz(final String value) { - return (HttpServletRequest) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { HttpServletRequest.class }, - (proxy, method, args) -> "getHeader".equals(method.getName()) ? value : null); - } - - /** - * Encodes credentials only. Header separator tests must build their header literally, because - * {@link #basic(String)} hardcodes a single space and would hide the very divergence they check. - */ - private static String token(final String credentials) { - return Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); - } - - private static String basic(final String credentials) { - return "Basic " + token(credentials); - } - - /** Builds a one-character string without a source escape, keeping raw break characters out of this file. */ - private static String ch(final int codePoint) { - return String.valueOf((char) codePoint); - } - - /** - * {@code /sso} is anonymous, so whatever one rejected handshake writes to the log is what an - * unbounded loop of them writes. Every failure the client's own token decides must therefore be - * reported by message ({@code SsoStateException}) rather than by stack trace - * ({@code SsoLoginException}). - * - *

{@code GSSException} is the one that used to be reported by trace, and it is the cheapest - * of the three to provoke: a token of three Base64 characters decodes and then fails inside - * {@code acceptSecContext}. A replayed authenticator lands here too. - */ - @Test - public void test_isHandshakeRefusal_coversEveryClientChosenFailure() { - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - - // The header the library refuses to try at all. - assertTrue(authenticator.isHandshakeRefusal(new UnsupportedOperationException("NTLM not supported"))); - // The token the strict Base64 decoder rejects. - assertTrue(authenticator.isHandshakeRefusal(new IllegalArgumentException("Illegal base64 character"))); - // The token that decodes but the acceptor will not take. - assertTrue(authenticator.isHandshakeRefusal(new GSSException(GSSException.DEFECTIVE_TOKEN))); - assertTrue(authenticator.isHandshakeRefusal(new GSSException(GSSException.FAILURE, -1, "Request is a replay (34)"))); - - // A fault of this server keeps its stack trace: initialization failures are wrapped in a - // plain SsoLoginException, and anything unforeseen from the library is not one of the three. - assertFalse(authenticator.isHandshakeRefusal(new SsoLoginException("Failed to initialize SPNEGO."))); - assertFalse(authenticator.isHandshakeRefusal(new NullPointerException())); - assertFalse(authenticator.isHandshakeRefusal(new RuntimeException("unexpected"))); - } - - @Test - public void test_getBasicRealm() { - // Only a Basic header carrying a realm yields one. - assertEquals("FOREIGN.EXAMPLE", SpnegoAuthenticator.getBasicRealm(basic("alice@FOREIGN.EXAMPLE:secret"))); - // The library strips a NetBIOS domain prefix before authenticating, so the realm is what - // follows '@', not the prefix. - assertEquals("FOREIGN.EXAMPLE", SpnegoAuthenticator.getBasicRealm(basic("CORP\\alice@FOREIGN.EXAMPLE:secret"))); - // A password containing '@' or ':' must not be mistaken for a realm. - assertEquals("FOREIGN.EXAMPLE", SpnegoAuthenticator.getBasicRealm(basic("alice@FOREIGN.EXAMPLE:p@ss:word"))); - - // No realm to check. - assertNull(SpnegoAuthenticator.getBasicRealm(null)); - assertNull(SpnegoAuthenticator.getBasicRealm(basic("alice:secret"))); - assertNull(SpnegoAuthenticator.getBasicRealm(basic("CORP\\alice:secret"))); - assertNull(SpnegoAuthenticator.getBasicRealm(basic("alice@:secret"))); - // Other schemes carry the realm in the principal instead and are validated after the - // handshake; they must not be decoded here. - assertNull(SpnegoAuthenticator.getBasicRealm("Negotiate YIIFoAYGKwYBBQUCoIIF")); - assertNull(SpnegoAuthenticator.getBasicRealm("Basic")); - // A malformed token belongs to the library to reject, not to this check. - assertNull(SpnegoAuthenticator.getBasicRealm("Basic !!!not-base64!!!")); - } - - @Test - public void test_getBasicRealm_realmFollowsTheLastAtSign() { - // Kerberos takes the realm after the LAST '@': KerberosPrincipal("alice@a@PARTNER.EXAMPLE") - // normalizes to name "alice@PARTNER.EXAMPLE" and realm "PARTNER.EXAMPLE", and the library - // hands the typed name straight to the login module, so that is the realm an AS-REQ would - // reach. Reading the first '@' instead names a realm that exists nowhere, which the allow - // list can only ever refuse. Built literally so the header is visible at the call site. - assertEquals("PARTNER.EXAMPLE", SpnegoAuthenticator.getBasicRealm("Basic " + token("alice@a@PARTNER.EXAMPLE:secret"))); - // A name ending in '@' names an empty realm, which KerberosPrincipal rejects outright, so - // there is nothing for the allow list to decide. - assertNull(SpnegoAuthenticator.getBasicRealm("Basic " + token("alice@a@:secret"))); - } - - @Test - public void test_getBasicRealm_separatorMatchesLibraryParsing() { - // SpnegoProvider#parseAuthHeader matches the scheme case-insensitively and then skips a run - // of any whitespace, possibly empty. Every header below is authenticated by the library, so - // each one has to yield its realm here as well; a header this check cannot read is a header - // the spnego.allowed.realms list cannot govern. These are built literally on purpose. - final String credentials = token("alice@PARTNER.COM:secret"); - assertEquals("PARTNER.COM", SpnegoAuthenticator.getBasicRealm("Basic " + credentials)); - assertEquals("PARTNER.COM", SpnegoAuthenticator.getBasicRealm("Basic\t" + credentials)); - assertEquals("PARTNER.COM", SpnegoAuthenticator.getBasicRealm("Basic\t " + credentials)); - // No separator at all: the library takes everything after the scheme as the token. - assertEquals("PARTNER.COM", SpnegoAuthenticator.getBasicRealm("Basic" + credentials)); - assertEquals("PARTNER.COM", SpnegoAuthenticator.getBasicRealm("basic\t" + credentials)); - assertEquals("PARTNER.COM", SpnegoAuthenticator.getBasicRealm("BASIC" + credentials)); - - // A user name without a realm names nothing to check, whichever separator was used. - assertNull(SpnegoAuthenticator.getBasicRealm("Basic\t" + token("alice:secret"))); - assertNull(SpnegoAuthenticator.getBasicRealm("Basic" + token("CORP\\alice:secret"))); - } - - @Test - public void test_getBasicRealm_ignoresNonBasicAndTokenlessHeaders() { - // A Negotiate token is not Basic credentials; decoding it here would invent a realm from - // arbitrary bytes. The realm of that path comes from the principal after the handshake. - assertNull(SpnegoAuthenticator.getBasicRealm("Negotiate " + token("alice@PARTNER.COM:secret"))); - // A scheme with no token authenticates nobody, so there is no realm to reject. - assertNull(SpnegoAuthenticator.getBasicRealm("Basic")); - assertNull(SpnegoAuthenticator.getBasicRealm("Basic ")); - assertNull(SpnegoAuthenticator.getBasicRealm("Basic\t")); - assertNull(SpnegoAuthenticator.getBasicRealm("")); - assertNull(SpnegoAuthenticator.getBasicRealm("Basi")); - } - - @Test - public void test_sanitizeForLog() { - assertEquals("CORP.EXAMPLE", SpnegoAuthenticator.sanitizeForLog("CORP.EXAMPLE")); - // A newline would otherwise let an unauthenticated client forge a log line. - assertEquals("EVIL??WARN forged", SpnegoAuthenticator.sanitizeForLog("EVIL\r\nWARN forged")); - final String bounded = SpnegoAuthenticator.sanitizeForLog("R".repeat(200)); - assertEquals(SpnegoAuthenticator.MAX_LOGGED_REALM_LENGTH + 3, bounded.length()); - assertTrue(bounded.endsWith("...")); - } - - @Test - public void test_rejectDisallowedBasicRealm_rejectsForeignRealm() { - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator() { - @Override - protected boolean isAllowedRealm(final String realm) { - return false; - } - }; - final SsoLoginException e = assertThrows(SsoLoginException.class, - () -> authenticator.rejectDisallowedBasicRealm(requestWithAuthz(basic("alice@FOREIGN.EXAMPLE:secret")))); - assertTrue(e.getMessage().contains("FOREIGN.EXAMPLE")); - assertTrue(e.getMessage().contains("spnego.allowed.realms")); - // The decoded token holds the password; it must never reach the message or the log. - assertFalse(e.getMessage().contains("secret")); - } - - @Test - public void test_rejectDisallowedBasicRealm_rejectsForeignRealmBehindNonSpaceSeparator() { - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator() { - @Override - protected boolean isAllowedRealm(final String realm) { - return false; - } - }; - // The library authenticates a tab-separated header, so the allow list has to reach it too. - // SsoStateException, not a plain SsoLoginException: /sso is anonymous, and SsoAction logs a - // full stack trace for anything else, which one crafted header per request would exploit. - final SsoStateException e = assertThrows(SsoStateException.class, - () -> authenticator.rejectDisallowedBasicRealm(requestWithAuthz("Basic\t" + token("alice@FOREIGN.EXAMPLE:secret")))); - assertTrue(e.getMessage().contains("FOREIGN.EXAMPLE")); - assertFalse(e.getMessage().contains("secret")); - } - - @Test - public void test_rejectDisallowedBasicRealm_acceptsAllowedRealm() { - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator() { - @Override - protected boolean isAllowedRealm(final String realm) { - return "PARTNER.EXAMPLE".equals(realm); - } - }; - authenticator.rejectDisallowedBasicRealm(requestWithAuthz(basic("alice@PARTNER.EXAMPLE:secret"))); - } - - @Test - public void test_rejectDisallowedBasicRealm_skipsCheckWhenNoRealmIsNamed() { - // A plain user name and a non-Basic scheme must not reach the allow list, because - // isAllowedRealm resolves the server realm through the library and would otherwise force - // SPNEGO initialization on every Negotiate handshake. Any such attempt fails here, since - // this authenticator has no usable SPNEGO configuration. - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - authenticator.rejectDisallowedBasicRealm(requestWithAuthz(null)); - authenticator.rejectDisallowedBasicRealm(requestWithAuthz("Negotiate YIIFoAYGKwYBBQUCoIIF")); - authenticator.rejectDisallowedBasicRealm(requestWithAuthz(basic("alice:secret"))); - authenticator.rejectDisallowedBasicRealm(requestWithAuthz(basic("CORP\\alice:secret"))); - } - - @Test - public void test_getLoginCredential_rejectedAuthorizationHeaderIsAStateException() { - // "Negotiate or Basic Only" is what SpnegoProvider#getAuthScheme raises for a header whose - // scheme is neither Negotiate nor Basic, and for a Basic header carrying no token. The - // library also raises UnsupportedOperationException for Basic once basicSupported is false - // and for an NTLM token it cannot downgrade. All three are decided by the client, and /sso - // is anonymous, so a stack trace per attempt would let an unauthenticated client fill the - // log -- SsoStateException, which SsoAction logs message-only. - addMockRequestHeader(Constants.AUTHZ_HEADER, "Bearer " + token("alice@PARTNER.EXAMPLE")); - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator() { - @Override - protected org.codelibs.spnego.SpnegoAuthenticator getAuthenticator() { - throw new UnsupportedOperationException("Negotiate or Basic Only"); - } - }; - final SsoStateException e = assertThrows(SsoStateException.class, authenticator::getLoginCredential); - assertTrue(e.getMessage().contains("Negotiate or Basic Only")); - // The header is echoed masked, so the scheme survives and the credential does not. - assertTrue(e.getMessage().contains("Bearer ***")); - assertFalse(e.getMessage().contains(token("alice@PARTNER.EXAMPLE"))); - } - - @Test - public void test_getLoginCredential_initializationFaultKeepsItsStackTrace() { - // The boundary the case above must not cross, and the reason it tests the thrown type - // rather than the cause chain. SpnegoFilterConfig raises UnsupportedOperationException for - // an invalid login module too -- no storeKey, a login module class it does not support, a - // control flag other than REQUIRED -- and those are server-side faults the operator needs - // the trace for. They are harmless here only because getAuthenticator() has already wrapped - // them in a plain SsoLoginException, so the thrown type is no longer the one being matched. - // Matching on the cause instead would find the nested UnsupportedOperationException and - // silently demote every initialization failure to a message-only log; this goes red first. - addMockRequestHeader(Constants.AUTHZ_HEADER, "Negotiate YIIFoAYGKwYBBQUCoIIF"); - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator() { - @Override - protected org.codelibs.spnego.SpnegoAuthenticator getAuthenticator() { - throw new SsoLoginException("Failed to initialize SPNEGO.", - new UnsupportedOperationException("Login Module for server does not have the storeKey option.")); - } - }; - final SsoLoginException e = assertThrows(SsoLoginException.class, authenticator::getLoginCredential); - assertFalse(e instanceof SsoStateException); - } - - /** - * Drives the library's own header parser and token decoder, so the exception type the mapping - * below relies on is pinned against the shipping spnego jar rather than assumed. - * - *

- * {@code SpnegoProvider#getAuthScheme} is public, but the {@code SpnegoAuthScheme} it returns is - * a package-private final class whose {@code getToken()} is package private too, so both are - * reached by reflection. Library and Fess both load from the class path (the unnamed module), - * so {@code setAccessible(true)} succeeds without any {@code --add-opens}. - *

- * - * @param header the raw Authorization header value - * @throws Throwable whatever the library raises, unwrapped from the reflective call - */ - private void decodeLibraryToken(final String header) throws Throwable { - final Method getAuthScheme = SpnegoProvider.class.getMethod("getAuthScheme", String.class); - final Object scheme; - try { - scheme = getAuthScheme.invoke(null, header); - } catch (final InvocationTargetException e) { - throw e.getCause(); - } - assertNotNull(scheme); - final Method getToken = scheme.getClass().getDeclaredMethod("getToken"); - getToken.setAccessible(true); - try { - getToken.invoke(scheme); - } catch (final InvocationTargetException e) { - throw e.getCause(); - } - } - - @Test - public void test_libraryRaisesIllegalArgumentExceptionForAMalformedToken() { - // SpnegoProvider#parseAuthHeader does not validate the token -- it accepts any non-empty - // trimmed remainder -- so the strict decoder behind SpnegoAuthScheme#getToken is what - // rejects it. SpnegoProvider#negotiate evaluates that decode at the top of the method, - // before any scheme dispatch and before the 401 is written, so every shape below reaches it - // whatever the spnego.allow.* settings say. This is the contract the mapping in - // getLoginCredential depends on; if a library upgrade changed the type, this goes red - // first instead of the demotion silently ceasing to apply. - assertThrows(IllegalArgumentException.class, () -> decodeLibraryToken("Negotiate ###")); - assertThrows(IllegalArgumentException.class, () -> decodeLibraryToken("Basic ###")); - // Valid base64 alphabet, but not a valid length. - assertThrows(IllegalArgumentException.class, () -> decodeLibraryToken("Negotiate a")); - // The scheme is matched case-insensitively and the separator is any run of whitespace, so a - // lower-case scheme behind a tab decodes -- and fails -- exactly the same way. - assertThrows(IllegalArgumentException.class, () -> decodeLibraryToken("negotiate" + ch(0x09) + "###")); - // The control: a scheme the library refuses outright is the other family, and it is raised - // by getAuthScheme before any token exists. Both families are demoted, but keeping the - // distinction visible here documents why the mapping names two types rather than one. - assertThrows(UnsupportedOperationException.class, () -> decodeLibraryToken("Digest abc")); - } - - @Test - public void test_getLoginCredential_malformedTokenIsAStateException() { - // The client chose the token, /sso is anonymous, and the header is echoed masked, so this - // is a rejected request rather than a fault: SsoStateException, which SsoAction logs - // message-only. Before this mapping existed the same request produced a full stack trace - // per attempt, which let an unauthenticated client fill the log. - addMockRequestHeader(Constants.AUTHZ_HEADER, "Negotiate ###"); - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator() { - @Override - protected org.codelibs.spnego.SpnegoAuthenticator getAuthenticator() { - throw new IllegalArgumentException("Illegal base64 character 23"); - } - }; - final SsoStateException e = assertThrows(SsoStateException.class, authenticator::getLoginCredential); - assertTrue(e.getMessage().contains("Illegal base64 character 23")); - assertTrue(e.getMessage().contains("Negotiate ***")); - } - - @Test - public void test_getLoginCredential_initializationFaultWithAnIllegalArgumentCauseKeepsItsStackTrace() { - // The same boundary as the UnsupportedOperationException case above, for the type added - // alongside it. SpnegoFilterConfig raises IllegalArgumentException for a missing - // spnego.krb5.conf, a missing spnego.login.conf and an exclude-dirs pattern it rejects, and - // those are server-side faults the operator needs the trace for. They are harmless here - // only because getAuthenticator() has already wrapped them in a plain SsoLoginException -- - // a FessSystemException, so no longer the type being matched. Matching on the cause instead - // would find the nested IllegalArgumentException and silently demote every one of them. - addMockRequestHeader(Constants.AUTHZ_HEADER, "Negotiate YIIFoAYGKwYBBQUCoIIF"); - final SpnegoAuthenticator authenticator = new SpnegoAuthenticator() { - @Override - protected org.codelibs.spnego.SpnegoAuthenticator getAuthenticator() { - throw new SsoLoginException("Failed to initialize SPNEGO.", - new IllegalArgumentException("Must specify a username and password or a keyTab.")); - } - }; - final SsoLoginException e = assertThrows(SsoLoginException.class, authenticator::getLoginCredential); - assertFalse(e instanceof SsoStateException); - } - - @Test - public void test_authenticatorInstantiation() { - // Verify authenticator can be instantiated without errors - SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - assertNotNull(authenticator); - } - - @Test - public void test_spnegoConfigClass() { - // Verify the inner SpnegoConfig class can be instantiated directly. - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - assertNotNull(config); - - // The filter name should be the fully qualified name of the outer class. - assertEquals(SpnegoAuthenticator.class.getName(), config.getFilterName()); - } - - @Test - public void test_securitySettings_allowBasic() throws Exception { - // Basic authentication remains enabled by default for compatibility. - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - assertEquals("true", config.getInitParameter(Constants.ALLOW_BASIC)); - } - - @Test - public void test_securitySettings_allowUnsecureBasic() throws Exception { - // Unsecure basic authentication (basic over plain HTTP) is disabled by default. - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - assertEquals("false", config.getInitParameter(Constants.ALLOW_UNSEC_BASIC)); - } - - @Test - public void test_getInitParameter_secureDefaults() { - // Verify the security-hardened defaults returned by SpnegoConfig#getInitParameter. - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - - // Localhost bypass must be off by default. - assertEquals("false", config.getInitParameter(Constants.ALLOW_LOCALHOST)); - // Unsecure basic auth over plain HTTP must be off by default. - assertEquals("false", config.getInitParameter(Constants.ALLOW_UNSEC_BASIC)); - // No pre-authentication credentials by default (keytab-based server login). - assertEquals("", config.getInitParameter(Constants.PREAUTH_USERNAME)); - assertEquals("", config.getInitParameter(Constants.PREAUTH_PASSWORD)); - // Basic auth stays enabled for compatibility. - assertEquals("true", config.getInitParameter(Constants.ALLOW_BASIC)); - // Delegation must be off by default. - assertEquals("false", config.getInitParameter(Constants.ALLOW_DELEGATION)); - } - - @Test - public void test_getInitParameter_loggerLevel_nonNumericFallsBack() { - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // A non-numeric level must be ignored and auto-detection used instead. - systemProperties.setProperty("spnego.logger.level", "abc"); - String level = config.getInitParameter(Constants.LOGGER_LEVEL); - assertNotNull(level); - assertFalse("abc".equals(level)); - // Auto-detection always yields a numeric level string. - assertTrue(level.chars().allMatch(Character::isDigit)); - - // A numeric level must be passed through unchanged. - systemProperties.setProperty("spnego.logger.level", "5"); - assertEquals("5", config.getInitParameter(Constants.LOGGER_LEVEL)); - } finally { - systemProperties.remove("spnego.logger.level"); - } - } - - @Test - public void test_isSupportedLoggerLevel() { - // The library switches on 1-7 and maps everything else, including 0, to INFO. - assertTrue(SpnegoAuthenticator.SpnegoConfig.isSupportedLoggerLevel("0")); - assertTrue(SpnegoAuthenticator.SpnegoConfig.isSupportedLoggerLevel("7")); - // Outside the documented range the value carries no meaning. - assertFalse(SpnegoAuthenticator.SpnegoConfig.isSupportedLoggerLevel("8")); - assertFalse(SpnegoAuthenticator.SpnegoConfig.isSupportedLoggerLevel("-1")); - // All-digit strings are not automatically parseable: the library uses Integer.parseInt. - assertFalse(SpnegoAuthenticator.SpnegoConfig.isSupportedLoggerLevel("99999999999")); - assertFalse(SpnegoAuthenticator.SpnegoConfig.isSupportedLoggerLevel("abc")); - } - - @Test - public void test_getInitParameter_loggerLevel_outOfRangeFallsBack() { - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // An all-digit value that overflows an int used to be passed through and made the - // library fail with NumberFormatException while building its configuration. - systemProperties.setProperty("spnego.logger.level", "99999999999"); - String level = config.getInitParameter(Constants.LOGGER_LEVEL); - assertFalse("99999999999".equals(level)); - assertTrue(SpnegoAuthenticator.SpnegoConfig.isSupportedLoggerLevel(level)); - - // A value above the documented range is ignored as well. - systemProperties.setProperty("spnego.logger.level", "8"); - assertFalse("8".equals(config.getInitParameter(Constants.LOGGER_LEVEL))); - - // The quietest documented level is still passed through unchanged. - systemProperties.setProperty("spnego.logger.level", "7"); - assertEquals("7", config.getInitParameter(Constants.LOGGER_LEVEL)); - } finally { - systemProperties.remove("spnego.logger.level"); - } - } - - @Test - public void test_getResourcePath_throwsWhenMissing() { - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - // A missing resource must raise SsoLoginException rather than returning null. - assertThrows(SsoLoginException.class, () -> config.getResourcePath("this-file-does-not-exist-xyz.conf")); - } - - @Test - public void test_isAllowedRealm_serverRealmMatches() { - SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - // The server's own realm is always allowed. - assertTrue(authenticator.isAllowedRealm("CORP.EXAMPLE", "CORP.EXAMPLE")); - // Realm comparison is case-insensitive. - assertTrue(authenticator.isAllowedRealm("corp.example", "CORP.EXAMPLE")); - } - - @Test - public void test_isAllowedRealm_rejectsForeignRealm() { - SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - // A realm other than the server realm is rejected when no allow list is configured. - assertFalse(authenticator.isAllowedRealm("EVIL.EXAMPLE", "CORP.EXAMPLE")); - } - - @Test - public void test_isAllowedRealm_allowlistPermitsForeignRealm() { - SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - systemProperties.setProperty("spnego.allowed.realms", "TRUSTED.EXAMPLE"); - // A realm explicitly listed in spnego.allowed.realms is permitted. - assertTrue(authenticator.isAllowedRealm("TRUSTED.EXAMPLE", "CORP.EXAMPLE")); - // A realm neither on the allow list nor the server realm is still rejected. - assertFalse(authenticator.isAllowedRealm("OTHER.EXAMPLE", "CORP.EXAMPLE")); - } finally { - systemProperties.remove("spnego.allowed.realms"); - } - } - - @Test - public void test_isAllowedRealm_backwardCompatWhenUndeterminable() { - SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - // When neither the server realm nor an allow list can be determined, any realm is - // accepted for backward compatibility (a warning is logged by the implementation). - assertTrue(authenticator.isAllowedRealm("ANY.EXAMPLE", "")); - } - - @Test - public void test_getProperty_blankFallsBackToDefault() { - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // The admin screen stores an empty string when an input is cleared, so a present but - // blank key must behave like an absent one instead of reaching the library as "". - systemProperties.setProperty("spnego.login.client.module", ""); - assertEquals("spnego-client", config.getInitParameter(Constants.CLIENT_MODULE)); - - systemProperties.setProperty("spnego.login.client.module", " "); - assertEquals("spnego-client", config.getInitParameter(Constants.CLIENT_MODULE)); - - // A real value is still passed through. - systemProperties.setProperty("spnego.login.client.module", "custom-client"); - assertEquals("custom-client", config.getInitParameter(Constants.CLIENT_MODULE)); - } finally { - systemProperties.remove("spnego.login.client.module"); - } - } - - @Test - public void test_getInitParameter_excludeDirsIsNotMapped() { - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - try { - // spnego.exclude.dirs is only honored by SpnegoHttpFilter, which Fess does not install, - // so the value must not be forwarded as if the exclusion were effective. - systemProperties.setProperty("spnego.exclude.dirs", "/api/"); - assertNull(config.getInitParameter(Constants.EXCLUDE_DIRS)); - } finally { - systemProperties.remove("spnego.exclude.dirs"); - } - } - - @Test - public void test_maskAuthzHeader() { - // Absent header. - assertEquals("null", SpnegoAuthenticator.maskAuthzHeader(null)); - // Negotiate token must be reduced to its scheme. - assertEquals("Negotiate ***", SpnegoAuthenticator.maskAuthzHeader("Negotiate YIIFxQYGKwYBBQUCoIIFuTCCBbW")); - // Basic credentials must not leak: even four base64 characters decode to three plain bytes - // of "user:password". - assertEquals("Basic ***", SpnegoAuthenticator.maskAuthzHeader("Basic dXNlcjpwYXNzd29yZA==")); - // A header without a scheme separator is fully masked. - assertEquals("***", SpnegoAuthenticator.maskAuthzHeader("dXNlcjpwYXNzd29yZA==")); - assertEquals("***", SpnegoAuthenticator.maskAuthzHeader("")); - assertEquals("***", SpnegoAuthenticator.maskAuthzHeader(" leading-space")); - } - - @Test - public void test_sanitizeForLog_unicodeLineBreaks() { - // \p{Cntrl} without the UNICODE flag covers ASCII only, so these three would otherwise reach - // the log intact and let an unauthenticated client forge a line in it. - assertEquals("EVIL?WARN forged", SpnegoAuthenticator.sanitizeForLog("EVIL" + ch(0x0085) + "WARN forged")); - assertEquals("EVIL?WARN forged", SpnegoAuthenticator.sanitizeForLog("EVIL" + ch(0x2028) + "WARN forged")); - assertEquals("EVIL?WARN forged", SpnegoAuthenticator.sanitizeForLog("EVIL" + ch(0x2029) + "WARN forged")); - } - - @Test - public void test_maskAuthzHeader_boundsAndSanitizesTheScheme() { - // The surviving scheme is client-controlled and ends up in an exception message that is - // logged, so it is bounded like the realm instead of echoing up to the container's header - // limit. - final String masked = SpnegoAuthenticator.maskAuthzHeader("S".repeat(8192) + " dXNlcjpwYXNzd29yZA=="); - assertEquals(SpnegoAuthenticator.MAX_LOGGED_REALM_LENGTH + 3 + 4, masked.length()); - assertTrue(masked.endsWith("... ***")); - - // Neither NUL nor NEL is whitespace, so both survive the scheme scan and have to be - // stripped before the value is logged. - assertEquals("Ba?ic ***", SpnegoAuthenticator.maskAuthzHeader("Ba" + ch(0x0000) + "ic dXNlcjpwYXNzd29yZA==")); - assertEquals("Ba?ic ***", SpnegoAuthenticator.maskAuthzHeader("Ba" + ch(0x0085) + "ic dXNlcjpwYXNzd29yZA==")); - - // A tab-separated header names its scheme rather than degrading to a bare mask. - assertEquals("Basic ***", SpnegoAuthenticator.maskAuthzHeader("Basic\tdXNlcjpwYXNzd29yZA==")); - } - - @Test - public void test_nullSafeLogout() { - SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - - // SPNEGO logout should return null (relies on Kerberos infrastructure) - String logoutUrl = authenticator.logout(null); - assertNull(logoutUrl); - } - - @Test - public void test_nullSafeGetResponse() { - SpnegoAuthenticator authenticator = new SpnegoAuthenticator(); - - // SPNEGO typically doesn't provide special response handling - org.lastaflute.web.response.ActionResponse response = authenticator.getResponse(org.codelibs.fess.sso.SsoResponseType.METADATA); - assertNull(response); - - response = authenticator.getResponse(org.codelibs.fess.sso.SsoResponseType.LOGOUT); - assertNull(response); - } - - @Test - public void test_unsupportedOperations() { - SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - // These two FilterConfig methods are never called by the library; make sure they fail loudly - // and name the class that actually threw. - UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, () -> config.getServletContext()); - assertTrue(e.getMessage().contains("SpnegoConfig")); - e = assertThrows(UnsupportedOperationException.class, () -> config.getInitParameterNames()); - assertTrue(e.getMessage().contains("SpnegoConfig")); - } -} diff --git a/src/test/java/org/codelibs/fess/sso/spnego/SpnegoBasicRealmLibraryParityTest.java b/src/test/java/org/codelibs/fess/sso/spnego/SpnegoBasicRealmLibraryParityTest.java deleted file mode 100644 index c6911cf38e..0000000000 --- a/src/test/java/org/codelibs/fess/sso/spnego/SpnegoBasicRealmLibraryParityTest.java +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.spnego; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; - -import org.codelibs.spnego.SpnegoProvider; -import org.junit.jupiter.api.Test; - -import javax.security.auth.kerberos.KerberosPrincipal; - -/** - * Pins {@link SpnegoAuthenticator#getBasicRealm(String)} against the spnego library's own header - * parser instead of against hardcoded expectations. - * - *

- * Why this exists: on the Basic path, the {@code spnego.allowed.realms} check is the only place the - * client-chosen Kerberos realm is ever inspected. After authentication the principal always carries - * the server realm, so a header this check cannot read is a header the allow list cannot - * govern. {@code getBasicRealm} used to split the scheme from the token on a literal space while - * the library skips any run of whitespace (or none at all), so {@code Basictoken} and - * {@code Basictoken} were authenticated by the library while the allow list saw no realm -- a - * complete bypass. The fix works by mirroring the library's parser, which means the two parsers can - * silently drift apart again the next time the library is upgraded. - *

- * - *

- * The sibling {@code SpnegoAuthenticatorTest} hardcodes the expected realms: it pins Fess's - * behaviour but never executes the library, so a change to {@code SpnegoProvider#parseAuthHeader} - * or to the library's base64 decoder would leave both suites green while the bypass returned. This - * test instead drives the real library parser for every header shape and compares what the library - * would authenticate against what Fess extracts. - *

- * - *

- * Deliberately not an equality assertion -- please do not "tighten" it into one. The - * security property is one-directional: Fess must never be less restrictive than the - * library, but it is free to be more. So for every header shape where the library would - * authenticate a credential naming realm {@code R}, this test requires only that Fess resolves a - * realm that is at least as narrow as {@code R}: never {@code null} (which means no check runs at - * all -- the original bypass), and never a value unrelated to {@code R} (which could match some - * other allow-list entry). A value that merely ends with {@code "@" + R} is accepted: an - * allow-list entry is a Kerberos realm name and contains no {@code '@'}, so such a value can only - * ever fail a list that {@code R} would pass, i.e. it over-rejects, which is safe. Asserting exact - * equality here would also couple this test to whether the user name is split at the first or the - * last {@code '@'}, which is a separate concern being changed independently. - *

- * - *

- * {@code SpnegoProvider#getAuthScheme} is public, but the {@code SpnegoAuthScheme} it returns is a - * package-private final class whose {@code isBasicScheme()} / {@code getToken()} are package - * private too, so they are reached by reflection. Both the library and Fess load from the class - * path (the unnamed module), so {@code setAccessible(true)} succeeds without any - * {@code --add-opens}. - *

- */ -public class SpnegoBasicRealmLibraryParityTest { - - /** Credentials whose realm is named by the header itself, used by the separator shapes. */ - private static final String REALM_CREDENTIALS = "alice@PARTNER.EXAMPLE:secret"; - - /** - * Every {@code Authorization} header shape the parity contract is checked over. - * - *

- * The separator shapes are spelled out literally on purpose: routing them through a helper that - * hardcodes {@code "Basic "} would hide the exact divergence this test exists to catch. - *

- * - * @return the header shapes to check - */ - private static List headerShapes() { - final String token = token(REALM_CREDENTIALS); - return List.of(// - // The separator between the scheme and the token: the library skips a run of any - // whitespace, possibly empty, and matches the scheme case-insensitively. - "Basic " + token, // - "Basic\t" + token, // - "Basic" + token, // - "basic " + token, // - "BASIC" + token, // - "Basic\f" + token, // - "Basic \n" + token, // - "Basic " + token + " ", // - // Whitespace inside the token, which neither side can base64-decode. - "Basic " + token.substring(0, 4) + " " + token.substring(4), // - // Credential shapes: NetBIOS prefix, a second '@', no colon, no realm, empty realm. - "Basic " + token("CORP\\alice@REALM.EXAMPLE:secret"), // - "Basic " + token("alice@sub@NESTED.EXAMPLE:secret"), // - "Basic " + token("alice-without-a-colon"), // - "Basic " + token("alice:secret"), // - "Basic " + token("alice@:secret"), // - // A scheme with no token at all. - "Basic ", // - "Basic", // - // Schemes that carry no Basic credentials. - "Negotiate " + token, // - "Bearer " + token, // - // A truncated token (still decodable) and a token that is not base64 at all. - "Basic " + token.substring(0, token.length() - 1), // - "Basic ????"); - } - - /** - * Requires Fess's realm extraction to be no weaker than the library's own parse of the same - * header, for every shape in {@link #headerShapes()}. - */ - @Test - public void test_basicRealmIsNeverWeakerThanTheLibraryParse() { - // Guards against a vacuous run: if the harness stopped reaching the library parser, every - // shape below would be skipped and the loop would assert nothing. - assertNotNull(libraryRealm("Basic " + token(REALM_CREDENTIALS)), - "the harness no longer reaches the library parser: even a canonical 'Basic ' header resolves to no realm"); - - final List governed = new ArrayList<>(); - for (final String header : headerShapes()) { - final String libraryRealm = libraryRealm(header); - if (libraryRealm == null) { - // The library authenticates no client-named realm here, so there is nothing for - // spnego.allowed.realms to govern and Fess may resolve whatever it likes. - continue; - } - governed.add(header); - final String fessRealm = SpnegoAuthenticator.getBasicRealm(header); - assertNotNull(fessRealm, visible(header) + ": the library authenticates realm " + libraryRealm - + ", but the allow-list check reads no realm at all, so spnego.allowed.realms cannot govern this header"); - assertTrue(libraryRealm.equals(fessRealm) || fessRealm.endsWith("@" + libraryRealm), - visible(header) + ": the library authenticates realm " + libraryRealm - + ", but the allow-list check reads the unrelated realm " + fessRealm - + ", which a different allow-list entry could match"); - } - assertTrue(governed.size() > 1, "no header shape reached the parity check beyond the canonical one"); - } - - /** - * Returns the Kerberos realm the library would authenticate the given header against, or null - * when the library authenticates no realm named by the header itself. - * - *

- * The scheme and token are parsed by the real {@code SpnegoProvider}; the steps after that - * follow {@code org.codelibs.spnego.SpnegoAuthenticator#doBasicAuth}, which decodes the token as - * UTF-8, splits it on the first colon, drops a NetBIOS {@code DOMAIN\} prefix and hands the - * remainder to Kerberos as the principal name. The realm is then derived by - * {@link KerberosPrincipal}, not by this test. - *

- * - * @param authzHeader the raw Authorization header value - * @return the realm the library would use, or null when it would authenticate nothing, would - * reject the credential, or would fall back to the local default realm - */ - private static String libraryRealm(final String authzHeader) { - final Object scheme; - try { - scheme = SpnegoProvider.getAuthScheme(authzHeader); - } catch (final UnsupportedOperationException e) { - // Neither Negotiate nor Basic, or a scheme with no token: nobody is authenticated. - return null; - } - if (scheme == null || !((Boolean) invoke(scheme, "isBasicScheme")).booleanValue()) { - return null; - } - final byte[] data; - try { - data = (byte[]) invoke(scheme, "getToken"); - } catch (final IllegalArgumentException e) { - // The library's own base64 decoder refused the token. - return null; - } - if (data.length == 0) { - return null; - } - final String[] basicData = new String(data, StandardCharsets.UTF_8).split(":", 2); - if (basicData.length != 2) { - // doBasicAuth throws IllegalArgumentException for a token without a colon. - return null; - } - final String username = basicData[0].substring(basicData[0].indexOf('\\') + 1); - if (username.indexOf('@') < 0) { - // The header names no realm, so Kerberos would use the local default realm rather than - // one the client chose. That is the server's own realm, which the allow list does not - // govern -- and resolving it here would depend on the krb5 config of the build machine. - return null; - } - try { - return new KerberosPrincipal(username, KerberosPrincipal.KRB_NT_PRINCIPAL).getRealm(); - } catch (final IllegalArgumentException e) { - // Not a principal name Kerberos accepts, so the login never leaves the server. - return null; - } - } - - /** - * Invokes a package-private no-argument method of the library's scheme object. - * - * @param scheme the {@code SpnegoAuthScheme} returned by the library - * @param methodName the method to invoke - * @return the method's return value - */ - private static Object invoke(final Object scheme, final String methodName) { - try { - final Method method = scheme.getClass().getDeclaredMethod(methodName); - method.setAccessible(true); - return method.invoke(scheme); - } catch (final InvocationTargetException e) { - if (e.getCause() instanceof RuntimeException) { - throw (RuntimeException) e.getCause(); - } - throw new AssertionError(methodName + "() failed on " + scheme.getClass().getName(), e.getCause()); - } catch (final ReflectiveOperationException e) { - throw new AssertionError( - "cannot reach " + methodName + "() on " + scheme.getClass().getName() + "; the library's scheme type changed", e); - } - } - - /** - * Base64-encodes credentials. Only the credentials: the header itself is always spelled out at - * the call site so that no separator is hidden behind a helper. - * - * @param credentials the decoded {@code user:password} string - * @return the base64 token - */ - private static String token(final String credentials) { - return Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); - } - - /** - * Escapes the whitespace that distinguishes the header shapes so a failure message names the - * shape that failed. - * - * @param header the raw header value - * @return the header with its break characters escaped - */ - private static String visible(final String header) { - return header.replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r").replace("\f", "\\f"); - } -} diff --git a/src/test/java/org/codelibs/fess/sso/spnego/SpnegoFilterConfigBoundaryTest.java b/src/test/java/org/codelibs/fess/sso/spnego/SpnegoFilterConfigBoundaryTest.java deleted file mode 100644 index 2865d1f60b..0000000000 --- a/src/test/java/org/codelibs/fess/sso/spnego/SpnegoFilterConfigBoundaryTest.java +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright 2012-2025 CodeLibs Project and the Others. - * - * 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.codelibs.fess.sso.spnego; - -import java.io.File; -import java.lang.reflect.Field; - -import javax.security.auth.login.Configuration; - -import org.codelibs.core.misc.DynamicProperties; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.spnego.SpnegoFilterConfig; -import org.codelibs.spnego.SpnegoHttpFilter.Constants; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; - -/** - * Boundary test that actually runs the SPNEGO library's configuration constructor against the - * values Fess produces. - * - *

- * A library regression once made the {@code SpnegoFilterConfig} constructor resolve the configured - * login.conf location with {@code new File(new URI(loginConfPath))}. Fess hands it a plain absolute - * file system path, because {@code SpnegoConfig#getResourcePath} resolves a packaged resource with - * {@code File#getAbsolutePath()}, so the constructor threw - * {@code IllegalArgumentException: URI is not absolute} and every SPNEGO login failed. Neither the - * existing Fess tests nor the library's own suite ever executed that constructor, so both builds - * stayed green while SSO was completely broken. - *

- * - *

- * This test crosses the boundary on purpose: it feeds a real {@link SpnegoAuthenticator.SpnegoConfig} - * into {@link SpnegoFilterConfig#getInstance(jakarta.servlet.FilterConfig)} with JAAS fixtures under - * {@code src/test/resources/spnego/} and asserts the resulting configuration. The first method pins - * the plain-path contract that broke; the second pins that a {@code file:} URI is still accepted, so - * the library fix cannot later be "simplified" into a path-only parser. - *

- * - *

- * The whole Fess suite shares one JVM, and both {@code SpnegoFilterConfig} and - * {@link Configuration} are JVM-wide cached singletons, so this class saves and restores that global - * state itself rather than relying on any other test to leave it clean. - *

- */ -public class SpnegoFilterConfigBoundaryTest extends UnitFessTestCase { - - /** Fess system property naming the JAAS login configuration resource. */ - private static final String SPNEGO_LOGIN_CONF = "spnego.login.conf"; - - /** Fess system property naming the Kerberos configuration resource. */ - private static final String SPNEGO_KRB5_CONF = "spnego.krb5.conf"; - - /** Classpath location of the JAAS fixture, namespaced so it cannot shadow Fess's own defaults. */ - private static final String TEST_LOGIN_CONF = "spnego/test_auth_login.conf"; - - /** Classpath location of the krb5 fixture (only set as a system property, never parsed here). */ - private static final String TEST_KRB5_CONF = "spnego/test_krb5.conf"; - - /** JVM system property the library sets from the login.conf init parameter. */ - private static final String JAAS_CONFIG_PROPERTY = "java.security.auth.login.config"; - - /** JVM system property the library sets from the krb5.conf init parameter. */ - private static final String KRB5_CONFIG_PROPERTY = "java.security.krb5.conf"; - - /** The library singleton captured before the test replaced it. */ - private Object savedFilterConfigInstance; - - /** The JAAS configuration captured before the test forced a reload. */ - private Configuration savedJaasConfiguration; - - /** The {@value #KRB5_CONFIG_PROPERTY} value captured before the library overwrote it. */ - private String savedKrb5ConfigProperty; - - /** The {@value #JAAS_CONFIG_PROPERTY} value captured before the library overwrote it. */ - private String savedJaasConfigProperty; - - @Override - protected void setUp(final TestInfo testInfo) throws Exception { - super.setUp(testInfo); - - // SpnegoFilterConfig caches its instance in a private static field and offers no reset, so - // the constructor under test would never run a second time in this JVM. - final Field instanceField = getInstanceField(); - savedFilterConfigInstance = instanceField.get(null); - instanceField.set(null, null); - - // Configuration is a JVM-wide cached singleton that reads java.security.auth.login.config - // only on first use. If an earlier test touched JAAS, the library's module lookup would see - // a stale configuration and fail with "The client module name was not found in the login - // file". Clearing it forces a reload from the property the library is about to set. - try { - savedJaasConfiguration = Configuration.getConfiguration(); - } catch (final Exception | Error e) { - savedJaasConfiguration = null; - } - Configuration.setConfiguration(null); - - savedKrb5ConfigProperty = System.getProperty(KRB5_CONFIG_PROPERTY); - savedJaasConfigProperty = System.getProperty(JAAS_CONFIG_PROPERTY); - - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - systemProperties.setProperty(SPNEGO_LOGIN_CONF, TEST_LOGIN_CONF); - systemProperties.setProperty(SPNEGO_KRB5_CONF, TEST_KRB5_CONF); - } - - @Override - protected void tearDown(final TestInfo testInfo) throws Exception { - try { - final DynamicProperties systemProperties = ComponentUtil.getSystemProperties(); - systemProperties.remove(SPNEGO_KRB5_CONF); - systemProperties.remove(SPNEGO_LOGIN_CONF); - - restoreSystemProperty(JAAS_CONFIG_PROPERTY, savedJaasConfigProperty); - restoreSystemProperty(KRB5_CONFIG_PROPERTY, savedKrb5ConfigProperty); - - Configuration.setConfiguration(savedJaasConfiguration); - - getInstanceField().set(null, savedFilterConfigInstance); - } finally { - super.tearDown(testInfo); - } - } - - /** - * Returns the library's private static singleton field, made accessible. - * - *

- * The field lives on a class loaded from the classpath (unnamed module), so no - * {@code --add-opens} is required. - *

- * - * @return the accessible {@code SpnegoFilterConfig.instance} field - * @throws Exception if the field no longer exists - */ - private static Field getInstanceField() throws Exception { - final Field field = SpnegoFilterConfig.class.getDeclaredField("instance"); - field.setAccessible(true); - return field; - } - - /** - * Restores a JVM system property, removing it when it was not set before. - * - * @param key the property name - * @param value the captured value, or null if the property was absent - */ - private static void restoreSystemProperty(final String key, final String value) { - if (value == null) { - System.clearProperty(key); - } else { - System.setProperty(key, value); - } - } - - /** - * The regression discriminator: the library must accept the plain absolute path Fess supplies. - * - * @throws Exception if the library rejects the configuration - */ - @Test - public void test_getInstance_acceptsPlainAbsolutePathFromClasspath() throws Exception { - final SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig(); - - // Contract: Fess hands the library a plain absolute path, not a file: URI. Pinning this - // here makes a future library that quietly re-requires a URI fail readably. - final String loginConf = config.getInitParameter(Constants.LOGIN_CONF); - assertFalse(loginConf.startsWith("file:")); - assertTrue(new File(loginConf).isAbsolute()); - - // The boundary: this constructor is what the regression broke. - final SpnegoFilterConfig result = SpnegoFilterConfig.getInstance(config); - assertNotNull(result); - - // toString() is the only public view of the parsed state. - final String s = result.toString(); - assertTrue(s.contains("clientLoginModule=spnego-client")); - assertTrue(s.contains("serverLoginModule=spnego-server")); - assertTrue(s.contains("canUseKeyTab=true")); - assertTrue(s.contains("allowBasic=true")); - assertTrue(s.contains("allowUnsecure=false")); - assertTrue(s.contains("allowLocalhost=false")); - - // The library forwards the same value to JAAS, which accepts a path as well as a URL. - assertEquals(loginConf, System.getProperty(JAAS_CONFIG_PROPERTY)); - } - - /** - * Control: a {@code file:} URI must keep working too, so the fix is not narrowed to paths only. - * - * @throws Exception if the library rejects the configuration - */ - @Test - public void test_getInstance_alsoAcceptsFileUri() throws Exception { - final SpnegoAuthenticator.SpnegoConfig config = new SpnegoAuthenticator.SpnegoConfig() { - @Override - protected String getResourcePath(final String path) { - return new File(super.getResourcePath(path)).toURI().toString(); - } - }; - - final String loginConf = config.getInitParameter(Constants.LOGIN_CONF); - assertTrue(loginConf.startsWith("file:")); - - final SpnegoFilterConfig result = SpnegoFilterConfig.getInstance(config); - assertNotNull(result); - - final String s = result.toString(); - assertTrue(s.contains("clientLoginModule=spnego-client")); - assertTrue(s.contains("serverLoginModule=spnego-server")); - assertTrue(s.contains("canUseKeyTab=true")); - - assertEquals(loginConf, System.getProperty(JAAS_CONFIG_PROPERTY)); - } -}