Ping MFA with Shibboleth IDP 5

Dan McLaughlin dmclaughlin at tech-consortium.com
Mon Aug 25 18:23:06 UTC 2025


Maybe I'll document it, where do you think a good place to put it
would be?  Just the distribution list?

I still feel like I might not be doing it right because I'm having to
create a custom Java bean to get the email from our custom
Authn/Password JAAS login module, which we use to authenticate against
database tables. Maybe it's the only way; it just seems overkill to
have to write custom code to get the NameID from Authn/Password.
Right now, I'm just hardcoding the email in my custom Java bean
because I'm still trying to figure out how to get it from our JAAS
login module since we don't use the typical UserPrincipalName.

Here is the current setup I have, and everything is working except I
have to finish figuring out how to get the email from JAAS login
module.

global.xml
<bean id="pingOneNameIDLookup" class="com.my.package.NameIDLookup"
init-method="initialize" destroy-method="destroy"/>

relying-party.xml
<bean id="PingOneUpstream" parent="RelyingPartyByName"
c:relyingPartyIds="PingTrustedIDHere">
  <property name="profileConfigurations">
    <list>
      <bean parent="SAML2.SSO"
        p:forceAuthn="true"
        p:encryptAssertions="true"
        p:encryptAttributes="true"
        p:maximumSPSessionLifetime="PT1H"
        p:skipEndpointValidationWhenSigned="false"
        p:includeAttributeStatement="true"
        p:randomizeFriendlyName="false"
        p:nameIDFormatPrecedence="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
        p:ignoreScoping="false"
        p:checkAddress="true"
        p:proxyCount="0"
        p:proxiedAuthnInstant="true"
        p:suppressAuthenticatingAuthority="false"
        p:maximumTimeSinceAuthn="PT5M"
        p:requireSignedRequests="true"
        p:signRequests="true">
      </bean>
    </list>
  </property>
</bean>

metadata-providers.xml

<MetadataProvider id="PingOneHTTP" xsi:type="FileBackedHTTPMetadataProvider"
    xmlns="urn:mace:shibboleth:2.0:metadata"
    metadataURL="https://pingidp.mydomain.com/pf/federation_metadata.ping?PartnerSpId=https%3A%2F%2Fshib-idp.mydomain.com%2Fsecure%2Fidp%2Fshibboleth"
    backingFile="%{idp.home}/metadata/pingonemfa-idp.xml"
    minRefreshDelay="PT15M" maxRefreshDelay="PT8H">

    <!-- Validate PingOne's metadata signature -->
    <MetadataFilter xsi:type="SignatureValidation"
      certificateFile="%{idp.home}/credentials/pingonemfa-signing.crt"
      requireSignedRoot="false"/>

    <!-- Require finite validUntil -->
<!--    <MetadataFilter xsi:type="RequiredValidUntil"
maxValidityInterval="P14D"/>-->

    <!-- Keep only IdP role -->
    <MetadataFilter xsi:type="EntityRole"
removeRolelessEntityDescriptors="true"
removeEmptyEntitiesDescriptors="true">
      <RetainedRole>md:IDPSSODescriptor</RetainedRole>
    </MetadataFilter>
  </MetadataProvider>


authn.properties
idp.authn.flows = MFA
idp.authn.SAML.NameIDLookupStrategy = pingOneNameIDLookup

idp.authn.SAML.proxyEntityID = PingTrustedIDHere
idp.authn.SAML.discoveryRequired = false
idp.authn.SAML.supportedPrincipals = \
  saml2/https://refeds.org/profile/mfa, \
  saml2/urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport, \
  saml2/urn:oasis:names:tc:SAML:2.0:ac:classes:Password

idp.authn.MFA.reuseCondition = shibboleth.Conditions.FALSE
idp.authn.MFA.supportedPrincipals = \
    saml2/https://refeds.org/profile/mfa, \
    saml2/urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport, \
    saml2/urn:oasis:names:tc:SAML:2.0:ac:classes:Password

idp_install.properties
idp.initial.modules=+idp.authn.MFA

mfa-authn-config.xml

<util:map id="shibboleth.authn.MFA.TransitionMap">
  <!-- First step: Password -->
  <entry key="">
    <bean parent="shibboleth.authn.MFA.Transition"
      p:nextFlow="authn/Password" />
  </entry>

  <!-- Second step: PingOne via the SAML login flow -->
  <entry key="authn/Password">
    <bean parent="shibboleth.authn.MFA.Transition"
      p:nextFlow="authn/SAML" />
  </entry>

  <!-- Implicit final rule: end with whatever the last step returned -->
</util:map>

Java Bean com.my.package.NameIDLookup

package com.my.package;

import java.util.Set;
import java.util.function.Function;

import org.opensaml.profile.context.ProfileRequestContext;
import org.opensaml.saml.saml2.core.NameID;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;

import net.shibboleth.idp.authn.principal.UsernamePrincipal;

import net.shibboleth.idp.attribute.IdPAttribute;
import net.shibboleth.idp.attribute.IdPAttributeValue;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * <h1>NameIDLookup</h1>
 *
 * Supplies the identifier to insert into the <em>upstream</em> SAML
 * {@code AuthnRequest}'s {@code <Subject>/<NameID>} when this IdP proxies to an
 * external IdP (PingOne in our case) via the {@code authn/SAML} login flow.
 *
 * <h2>Resolution order</h2>
 * <ol>
 *   <li>{@link UsernamePrincipal}</li>
 *   <li>Any {@link java.security.Principal} name on the {@code Subject}</li>
 *   <li>Attributes (in order): {@code cn}, {@code mail}, {@code uid}</li>
 * </ol>
 *
 * <h2>Configuration</h2>
 * <pre>{@code
 * <!-- global.xml -->
 * <bean id="pingOneNameIDLookup" class="com.my.package.NameIDLookup"/>
 *
 * <!-- authn.properties -->
 * idp.authn.SAML.NameIDLookupStrategy = pingOneNameIDLookup
 * }</pre>
 *
 * Returning {@code null} omits the Subject, letting the upstream IdP prompt.
 */
@SuppressWarnings({"unused","ConstantConditions"}) // instantiated by
Spring, Defensive: subject is usually non-null, but we check anyway
public class NameIDLookup implements Function<ProfileRequestContext, NameID> {

  private static final Logger logger =
LoggerFactory.getLogger(NameIDLookup.class);
  private static final String[] ATTRIBUTE_KEYS = { "username",
"email", "mail", "cn", "uid" };
  private static final String HARDCODED_EMAIL = "testmfa at testdomain.com";

  public void initialize() {
    logger.info("NameIDLookup: initialized");
  }

  public void destroy() {
    logger.info("NameIDLookup: destroyed");
  }

  @Override
  public NameID apply(final ProfileRequestContext prc) {
    logger.warn("NameIDLookup: HARD-CODING email for upstream SAML
request (temporary)");
    return buildNameIDWithBestFormat();
  }

  /** Returns the first principal name from a (non-null) set or {@code
null} if empty. */
  private static <T extends java.security.Principal> String
firstPrincipalName(final Set<T> principals) {
    return (principals == null || principals.isEmpty()) ? null :
principals.iterator().next().getName();
  }

  /**
   * Extracts the first string-like value from an {@link IdPAttribute}.
   * Uses {@link IdPAttributeValue#getDisplayValue()} for a safe
string representation.
   */
  private static String firstStringValue(final IdPAttribute attr) {
    if (attr == null) return null;
    final java.util.List<IdPAttributeValue> values = attr.getValues();
// non-null list
    if (values.isEmpty()) return null;
    final String s = values.getFirst().getDisplayValue(); // safe: a
list is non-empty here
    return s.isEmpty() ? null : s;
  }

  /** Build an OpenSAML NameID with the given value. Returns null if
value is blank. */
  private static NameID buildNameID() {
    if (NameIDLookup.HARDCODED_EMAIL == null ||
NameIDLookup.HARDCODED_EMAIL.isEmpty()) return null;
    final org.opensaml.core.xml.XMLObjectBuilderFactory bf =
XMLObjectProviderRegistrySupport.getBuilderFactory();
    @SuppressWarnings("unchecked")
    final org.opensaml.core.xml.XMLObjectBuilder<NameID> builder =
        (org.opensaml.core.xml.XMLObjectBuilder<NameID>)
bf.getBuilder(NameID.DEFAULT_ELEMENT_NAME);
    if (builder == null) return null;
    final NameID nameId = builder.buildObject(NameID.DEFAULT_ELEMENT_NAME);
    nameId.setValue(NameIDLookup.HARDCODED_EMAIL);
    return nameId;
  }

  /** Build a NameID and set the most appropriate Format based on the value. */
  private static NameID buildNameIDWithBestFormat() {
    final NameID nameId = buildNameID();
    if (nameId == null) return null;
    try {
      if (NameIDLookup.HARDCODED_EMAIL != null &&
NameIDLookup.HARDCODED_EMAIL.contains("@")) {
        nameId.setFormat("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
      } else {
        // Leave format unset ("unspecified") if it doesn't look like an email
        nameId.setFormat(null);
      }
    } catch (Exception e) {
      // ignore; format is optional
    }
    return nameId;
  }
}



--

Thanks,

Dan

On Mon, Aug 25, 2025 at 12:40 PM Cantor, Scott <cantor.2 at osu.edu> wrote:
>
> The Duo plugin is an implementation of an OpenID Connect client with some modifications to fit Duo's non-standard behavior and do some value-add on top of that.
>
> It's not a fit for anything else really, though we talked about potentially trying to collapse it into the main RP plugin someday. I doubt it happens before Duo itself Cisco's themselves into oblivion.
>
> >  My issue now is getting Ping to accept the Subject
> > from the AuthnRequest and not reprompt the user for
> > their username.
>
> That isn't exactly what the field in the request is for. There is no "login hint" concept in SAML.
>
> That field is about tailoring assertions if one is imnplementing an STS using SAML protocol, it was a finger in the eye of the WS-* people to prove we could do the kinds of things they did.
>
> -- Scott
>
>


More information about the users mailing list