Magiclink authentication for anonymous user

We have a requirement to allow anonymous user access to limited functionality based on something similar to a magiclink.

Is there are way to use the Action interface and suspension handler
https://backstage.forgerock.com/docs/am/7/apidocs/org/forgerock/openam/auth/node/api/Action.html#suspend(org.forgerock.openam.auth.node.api.SuspensionHandler)
in a scripted decision node to implement something similar to magiclink authentication for an anonymous user.

The documentation on the SuspensionHandler and usage of the same in a Scripted decision node appears to be very limited.

There is a reference to implementing this in IG < 7 Magic links with ForgeRock Access Management — Before v7 | by Stéphane Orluc | Medium

Though it recommends using Email suspend node in AM 7.x which we are on. Since our deployment does not have an idm integration and there is no identity associated with the authentication request.

Are there suggestions on approaches to implement this feature?

Thank you

1 Like

The Suspend action is not available in a scripted node. This means you’ll need to split the journey in two separate journey:

  • The first journey collect the user details and send the magic link by mail to the user
  • When the user follows the magic link, it resumes at the second journey.

Use a signed JWT embedded in the magic link, and which is validated by the second journey. The delicate part is how to end the first journey. A journey either ends with success (the session cookie is set) or fails (Login Failure message). The second option is preferable, security wise. A third option is to not end the journey at all - however, this will leave behind dangling authentication sessions, but they’ll eventually time out and be wiped. If you have implemented your own login page, it will be easier to handle either a login failure (ignore the error after the mail has been sent), or if the journey is not to ever end just stop the process from the front end at this level.

Regards

  • Patrick
2 Likes

Hi Patrick,
Thank you for the details. Do you have thoughts on how to best handle anonymous users?
Considering the email suspend node for example is not very configurable and pulls the email address/phone number from the user profile. For an anonymous user, how would that be approached.

Thank you
Ram

@rjeghanathan You can use /docs/am/7/apidocs/org/forgerock/openam/auth/node/api/SuspendedTextOutputCallback.html to create a suspension mechanism.

I was able to call the SuspensionHandler Interface using JavaImporter. However, I am not sure exactly how to use it yet.

1 Like

@patrickdiligent When you say Signed JWT embedded in the magicLink. How can we do this?

1 Like

In this particular case the mail suspend node will not help indeed. Instead a scripted decision node can either send the mail via the IDM api (so that to use the configured SMTP server) or connect directly the mail service api. Then you can do anything in that script to fill the necessary information into the magic link in order to resume the authentication.

1 Like

Hi Eduardo,

Here is an example in creating the JWT - note this example is achieving a forgotten password journey using the IDM mail REST API, in Identity Cloud - but this will give you an idea; note that this refers to two config placeholders (ESV in identity cloud), one for the HMAC key, and the other for the magic link URL (which invokes the second journey with the service parameter).

/*
  - Data made available by nodes that have already executed are available in the sharedState variable.
  - The script should set outcome to either "true" or "false".
 */

var config = {
  issuer: "myissuer",
  audience: "myaudience",
  validityMinutes: 1,
  nodeName: "***createJwt-"
};

var claims = {
  uid: sharedState.get("_id")
};

var fr = JavaImporter(
    org.forgerock.openam.auth.node.api.Action,
    java.util.UUID,
    java.time.Clock,
    java.time.temporal.ChronoUnit,
    javax.crypto.spec.SecretKeySpec,
    org.forgerock.json.jose.builders.JwtBuilderFactory,
    org.forgerock.json.jose.jws.JwsAlgorithm,
    org.forgerock.json.jose.jws.handlers.SecretHmacSigningHandler,
    org.forgerock.json.jose.jwt.JwtClaimsSet,
    org.forgerock.secrets.SecretBuilder,
    org.forgerock.secrets.keys.SigningKey,
    org.forgerock.util.encode.Base64,
    org.forgerock.json.jose.jws.SignedJwt,
    org.forgerock.json.jose.jws.EncryptedThenSignedJwt,
    org.forgerock.json.jose.jwe.SignedThenEncryptedJwt,
    org.forgerock.secrets.keys.VerificationKey,
    javax.crypto.spec.SecretKeySpec,
    org.forgerock.json.jose.jwe.JweAlgorithm,
    org.forgerock.json.jose.jwe.EncryptionMethod
);

logger.message(config.error + ": node executing");

outcome = "true";

var secret = systemEnv.getProperty("esv.hmac.jwt.signing");

var secretBytes = fr.Base64.decode(secret);
var secretBuilder = new fr.SecretBuilder(); 
secretBuilder.secretKey(new javax.crypto.spec.SecretKeySpec(secretBytes, "Hmac")); 
secretBuilder.stableId(config.issuer).expiresIn(1, fr.ChronoUnit.MINUTES, fr.Clock.systemUTC());
var signingKey = fr.SigningKey(secretBuilder);

var signingHandler = new fr.SecretHmacSigningHandler(signingKey);
             
var iat = new Date();
var iatTime = iat.getTime();

var jwtClaims = new fr.JwtClaimsSet();
jwtClaims.setIssuer(config.issuer);
jwtClaims.addAudience(config.audience);
jwtClaims.setIssuedAtTime(new Date());
jwtClaims.setExpirationTime(new Date(iatTime + (config.validityMinutes * 60 * 1000)));
jwtClaims.setClaims(claims);

var jwt = null;

jwt = new fr.JwtBuilderFactory()
  .jws(signingHandler)
  .headers()
  .alg(fr.JwsAlgorithm.HS512)                
  .done()
  .claims(jwtClaims)
  .build();

sharedState.put("jwt", jwt);

logger.message(config.nodeName + ": node exit");


Then creating the actual magic link and sending it via mail:

/*
  - Data made available by nodes that have already executed are available in the sharedState variable.
  - The script should set outcome to either "true" or "false".
 */

/**
 * @file This script sends a HOTP to a user via the IDM SendGrid Email Templating Service
 * NOTE - The use of SendGrid is not supported in Production and furthermore this service will be removed in due course.
 * @version 0.1.0
 * @keywords email mail hotp sharedState transientState templateService
 */

/**
 * Environment specific config 
 */

 var idmEndpoint = "https://<idm-url>/openidm/external/email?_action=sendTemplate";

 /**
  * Full Configuration 
  */
 
 var config = {
     ACCESS_TOKEN_STATE_FIELD: "idmAccessToken",
     templateID: "magicLink",
     nodeName: "***magicLinkEmailTemplateService"
 };
 
 /**
  * Node outcomes
  */
 
 var NodeOutcome = {
     PASS: "sent",
     FAIL: "noMail",
     ERROR: "error"
 };
 
var fr = JavaImporter(
  org.forgerock.util.encode.Base64,
  java.lang.String
);

 /**
  * Log an HTTP response
  * 
  * @param {Response} HTTP response object
  */
 
 function logResponse(response) {
     logger.message(config.nodeName + ": Scripted Node HTTP Response: " + response.getStatus() + ", Body: " + response.getEntity().getString());
 }
 
 /**
  * Send email via the IDM Email Service
  * 
  * @param {string} username - username of the user retrieved from sharedState
  * @param {string} accessToken - Access Token retrieved from transientState
  * @param {string} hotp - HOTP retrieved from sharedState
  * @param {string} mail - mail attribute retrieved from the idRepository
  */
 
 function sendMail(username, accessToken, link, mail) {
 
     var response;
 
     logger.message(config.nodeName + ": Sending email via the IDM email templating service for user: " + username + " with mail address: " + mail + " and JWT: " + jwt);
     logger.message(config.nodeName + ": Using this IDM email template: " + config.templateID);
     try {
        var request = new org.forgerock.http.protocol.Request();
        var requestBodyJson = {
            "templateName": config.templateID,
            "to": mail,
            "object": {
            //   "givenName": givenName,
                "link": link
            }
        };
        request.setMethod('POST');
        request.setUri(idmEndpoint);
        request.getHeaders().add("Authorization", "Bearer " + accessToken);
        request.getHeaders().add("Content-Type", "application/json");
        request.setEntity(requestBodyJson);
        response = httpClient.send(request).get();
     }
     catch (e) {
         logger.error(config.nodeName + ": Unable to call IDM Email endpoint. Exception: " + e);
         return NodeOutcome.ERROR;
     }
     logResponse(response);
 
     if (response.getStatus().getCode() === 200) {
         logger.message(config.nodeName + ": Email sent for user: " + username + " with email: " + mail);
         return NodeOutcome.PASS;
     }
     else if (response.getStatus().getCode() === 401) {
         logger.error(config.nodeName + ": Access token is invalid: " + response.getStatus() + " for user: " + username);
         return NodeOutcome.ERROR;
     }
     else if (response.getStatus().getCode() === 400) {
         logger.error(config.nodeName + ": Unable to retrieve template. HTTP Result: " + response.getStatus() + " for template: " + config.templateID);
         return NodeOutcome.ERROR;
     }
     else if (response.getStatus().getCode() === 404) {
         logger.error(config.nodeName + " IDM Email endpoint not found. HTTP Result: " + response.getStatus() + " for idmEndpoint: " + idmEndpoint);
         return NodeOutcome.ERROR;
     }
     //Catch all error 
     logger.error(config.nodeName + ": HTTP 5xx or Unknown error occurred. HTTP Result: " + response.getStatus());
     return NodeOutcome.ERROR;
 }
 
 /**
  * Node entry point
  */
 
 logger.message(config.nodeName + ": node executing");
 
 var username;
 var accessToken;
 var jwt;
 var mail;
 //var givenName;
 
 //if (!(username = sharedState.get("userName"))) {
 //    logger.error(config.nodeName + ": Unable to retrieve username from sharedState :" + sharedState);
 //    outcome = NodeOutcome.ERROR;
 //}
 if (!(username = sharedState.get("_id"))) {
     logger.error(config.nodeName + ": Unable to retrieve username from sharedState :" + sharedState);
     outcome = NodeOutcome.ERROR;
 }
 
 else if (!(accessToken = transientState.get(config.ACCESS_TOKEN_STATE_FIELD))) {
     logger.error(config.nodeName + ": Unable to retrieve Access Token from transientState");
     outcome = NodeOutcome.ERROR;
 }
 
 else if (!(jwt = sharedState.get("jwt"))) {
     logger.error(config.nodeName + ": Unable to retrieve the JWT from sharedState");
     outcome = NodeOutcome.ERROR;
 }
 
 //else if (!(mail = sharedState.get("mail"))) {
  //   logger.error(config.nodeName + ": Unable to retrieve mail attribute from the sharedState");
  //   outcome = NodeOutcome.FAIL;
 //}

 else if (!(mail = String(idRepository.getAttribute(username, "mail").toArray()[0].toString()))) {
     logger.error(config.nodeName + ": Unable to retrieve mail attribute from the idRepository");
     outcome = NodeOutcome.FAIL;
 }

 //else if (!(givenName = sharedState.get("givenName"))) {
 //   logger.error(config.nodeName + ": Unable to retrieve givenName attribute from the idRepository");
 //   outcome = NodeOutcome.FAIL;
//}
 
 else {
        var link = systemEnv.getProperty("esv.magic.link.url") + "&magiclink=" + fr.Base64.encode(fr.String(jwt).getBytes());    
     outcome = sendMail(username, accessToken, link, mail);
 }

Then in the resuming journey, first collect the jwt from the parameters from a scripted decision node:

/*
  - Data made available by nodes that have already executed are available in the sharedState variable.
  - The script should set outcome to either "true" or "false".
 */

outcome = "true";

sharedState.put("jwt", requestParameters.get("magiclink").get(0));

Then next scripted decision node, validate it:

/*
  - Data made available by nodes that have already executed are available in the sharedState variable.
  - The script should set outcome to either "true" or "false".
 */

var config = {
  issuer: "myissuer",
  audience: "myaudience",
  validityMinutes: 1,
  nodeName: "validateJwt-*** "
};

var nodeOutcome = {
    ERROR: "error",
    INVALID: "invalid",
      EXPIRED: "expired",
      TRUE: "true"
};

var fr = JavaImporter(
    org.forgerock.openam.auth.node.api.Action,
    java.util.UUID,
    java.time.Clock,
    java.time.temporal.ChronoUnit,
    javax.crypto.spec.SecretKeySpec,
    org.forgerock.json.jose.builders.JwtBuilderFactory,
    org.forgerock.json.jose.jws.JwsAlgorithm,
    org.forgerock.json.jose.jws.handlers.SecretHmacSigningHandler,
    org.forgerock.json.jose.jwt.JwtClaimsSet,
    org.forgerock.secrets.SecretBuilder,
    org.forgerock.secrets.keys.SigningKey,
    org.forgerock.util.encode.Base64,
    org.forgerock.json.jose.jws.SignedJwt,
    org.forgerock.json.jose.jws.EncryptedThenSignedJwt,
    org.forgerock.json.jose.jwe.SignedThenEncryptedJwt,
    org.forgerock.secrets.keys.VerificationKey,
    javax.crypto.spec.SecretKeySpec,
    org.forgerock.json.jose.jwe.JweAlgorithm,
    org.forgerock.json.jose.jwe.EncryptionMethod,
      java.lang.String
);

outcome ="true";

var jwt64String = sharedState.get("jwt");
var secret = systemEnv.getProperty("esv.hmac.jwt.signing");

if (jwt64String && secret) {
  var secretBytes = fr.Base64.decode(secret);
  var secretBuilder = new fr.SecretBuilder(); 
  secretBuilder.secretKey(new javax.crypto.spec.SecretKeySpec(secretBytes, "Hmac")); 
  secretBuilder.stableId(config.issuer).expiresIn(1, fr.ChronoUnit.MINUTES, fr.Clock.systemUTC());
  var verificationKey = new fr.VerificationKey(secretBuilder);

  var jwtString = new fr.String(fr.Base64.decode(jwt64String));
  logger.message(config.nodeName + " JWT string is " + jwtString);
  var jwt = new fr.JwtBuilderFactory().reconstruct(jwtString, fr.SignedJwt);
  var verificationHandler = new fr.SecretHmacSigningHandler(verificationKey);

  if (!jwt.verify(verificationHandler)) {
    logger.message(config.nodeName + "JWT signature did not verify");
    outcome = nodeOutcome.INVALID; 
  } else {
    var jwtClaims = jwt.getClaimsSet();
    var jwtIssuer = jwtClaims.getIssuer();
    var jwtIssuedAt = jwtClaims.getIssuedAtTime();
    var jwtExpiry = jwtClaims.getExpirationTime();
    var now = new Date();

    if (jwtIssuer != config.issuer) {
      logger.message(config.nodeName + "Issuer in JWT [" + jwtIssuer + "] doesn't match expected issuer [" + config.issuer + "]");
      outcome = nodeOutcome.INVALID;
    } else if (jwtIssuedAt.after(now)) {
      logger.message(config.nodeName + "JWT issued in the future [" + jwtIssuedAt + "]");
      outcome = nodeOutcome.INVALID;
    } else if (jwtExpiry.before(now)) {
      logger.message(config.nodeName + "JWT expired at [" + jwtExpiry + "]");
      outcome = nodeOutcome.EXPIRED;
    } else {
      var objectAttributes = {
        "_id" : jwtClaims.getClaim("uid")
      };
      sharedState.put("objectAttributes",objectAttributes);
      sharedState.put("claims", jwtClaims);
      outcome = nodeOutcome.TRUE;
    }
    

  }
} else {
    outcome = nodeOutcome.ERROR;
}
2 Likes