diff --git a/pom.xml b/pom.xml index 0c0ca1c39..b2051411c 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 3d4568ae6..bc34bc9be 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 e74cc4496..000000000 --- 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 0e9b8cc3a..000000000 --- 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 668bfaf8a..000000000 --- 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 107c13f92..000000000 --- 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 a37a7bb58..daa2a3380 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 42311a461..2d47374f1 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 1bff19dfd..000000000 --- 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 ad3dad266..000000000 --- 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 9784abf11..000000000 --- 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 4f469a3ce..000000000 --- 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 0c79071f3..000000000 --- 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 6f2121732..c497bb0eb 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 ce45e8d40..000000000 --- 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 cf8df9401..24be2be59 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 f5232f6e3..000000000 --- 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 21e9e2d81..2b6049a5d 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 3778c1fe2..7c9f73357 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 20953548b..000000000 --- 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 fc369e477..000000000 --- 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 63574c22a..000000000 --- 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 b6742e0e9..000000000 --- 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 c6911cf38..000000000 --- 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 2865d1f60..000000000 --- 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)); - } -}