Skip to content

Commit

Permalink
feat: allow integration of Agama flows into the authz challenge enpoi…
Browse files Browse the repository at this point in the history
…nt (#10587)

* docs: add relevant documentation #10460

Signed-off-by: jgomer2001 <[email protected]>

* feat: add supporting code for Challenge script #10460

Signed-off-by: jgomer2001 <[email protected]>

* feat: add Challenge script #10460

Signed-off-by: jgomer2001 <[email protected]>

---------

Signed-off-by: jgomer2001 <[email protected]>
  • Loading branch information
jgomer2001 authored Jan 10, 2025
1 parent 5091b56 commit 856f9fe
Show file tree
Hide file tree
Showing 13 changed files with 1,021 additions and 19 deletions.
File renamed without changes.
Binary file added docs/assets/agama/challenge-flow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 4 additions & 6 deletions docs/janssen-server/developer/agama/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,6 @@ We plan to offer a debugger in the future. In the meantime, you can do `printf`-

## Miscellaneous

### Does the engine support AJAX?

If you require a flow with no page refreshes, it could be implemented using AJAX calls as long as they align to the [POST-REDIRECT-GET](./advanced-usages.md#flow-advance-and-navigation) pattern, where a form is submitted, and as response a 302/303 HTTP redirection is obtained. Your Javascript code must also render UI elements in accordance with the data obtained by following the redirect (GET). Also, care must be taken in order to process server errors, timeouts, etc. In general, this requires a considerable amount of effort.

If you require AJAX to consume a resource (service) residing in the same domain of your server, there is no restriction - the engine is not involved. Interaction with external domains may require to setup CORS configuration appropriately in the authentication server.

### How to launch a flow?

A flow is launched by issuing an authentication request in a browser as explained [here](./jans-agama-engine.md#launching-flows).
Expand Down Expand Up @@ -195,3 +189,7 @@ Note the localization context (language, country, etc.) used in such a call is b
### Can Agama code be called from Java?

No. These two languages are supposed to play roles that should not be mixed, check [here](./agama-best-practices.md#about-flow-design).

### How to run flows from native applications instead of web browsers?

There is a separate doc page covering this aspect [here](./native-applications.md).
450 changes: 450 additions & 0 deletions docs/janssen-server/developer/agama/native-applications.md

Large diffs are not rendered by default.

317 changes: 317 additions & 0 deletions docs/script-catalog/authorization_challenge/AgamaChallenge.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
import io.jans.as.common.model.common.User;
import io.jans.as.common.model.session.AuthorizationChallengeSession;
import io.jans.as.server.authorize.ws.rs.AuthorizationChallengeSessionService;
import io.jans.as.server.service.UserService;
import io.jans.as.server.service.external.context.ExternalScriptContext;
import io.jans.model.SimpleCustomProperty;
import io.jans.model.custom.script.model.CustomScript;
import io.jans.model.custom.script.type.authzchallenge.AuthorizationChallengeType;
import io.jans.orm.PersistenceEntryManager;
import io.jans.service.cdi.util.CdiUtil;
import io.jans.service.custom.script.CustomScriptManager;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.jans.agama.engine.model.*;
import io.jans.agama.engine.misc.FlowUtils;
import io.jans.agama.engine.service.AgamaPersistenceService;
import io.jans.agama.NativeJansFlowBridge;
import io.jans.agama.engine.client.MiniBrowser;
import io.jans.as.model.configuration.AppConfiguration;
import io.jans.as.model.util.Base64Util;
import io.jans.util.*;

import jakarta.servlet.ServletRequest;
import java.io.IOException;
import java.util.*;

import org.json.*;

import static io.jans.agama.engine.client.MiniBrowser.Outcome.*;

public class AuthorizationChallenge implements AuthorizationChallengeType {

//private static final Logger log = LoggerFactory.getLogger(AuthorizationChallenge.class);
private static final Logger scriptLogger = LoggerFactory.getLogger(CustomScriptManager.class);

private String finishIdAttr;
private MiniBrowser miniBrowser;
private PersistenceEntryManager entryManager;
private AuthorizationChallengeSessionService deviceSessionService;

private boolean makeError(ExternalScriptContext context, AuthorizationChallengeSession deviceSessionObject,
boolean doRemoval, String errorId, JSONObject error, int status) {

JSONObject jobj = new JSONObject();
if (deviceSessionObject != null) {

if (doRemoval) {
entryManager.remove(deviceSessionObject.getDn(), AuthorizationChallengeSession.class);
} else {
jobj.put("auth_session", deviceSessionObject.getId());
}
}

String errId = errorId.toLowerCase();
jobj.put("error", errId);
jobj.put(errId, error);

context.createWebApplicationException(status, jobj.toString(2) + "\n");
return false;

}

private boolean makeUnexpectedError(ExternalScriptContext context, AuthorizationChallengeSession deviceSessionObject,
String description) {

JSONObject jobj = new JSONObject(Map.of("description", description));
return makeError(context, deviceSessionObject, true, "unexpected_error", jobj, 500);

}

private boolean makeMissingParamError(ExternalScriptContext context, String description) {

JSONObject jobj = new JSONObject(Map.of("description", description));
return makeError(context, null, false, "missing_param", jobj, 400);

}

private Pair<String, String> prepareFlow(String sessionId, String flowName) {

String msg = null;
try {
String qn = null, inputs = null;

int i = flowName.indexOf("-");
if (i == -1) {
qn = flowName;
} else if (i == 0) {
msg = "Flow name is empty";
} else {
qn = flowName.substring(0, i);
scriptLogger.info("Parsing flow inputs");
inputs = Base64Util.base64urldecodeToString(flowName.substring(i + 1));
}

if (qn != null) {
NativeJansFlowBridge bridge = CdiUtil.bean(NativeJansFlowBridge.class);
Boolean running = bridge.prepareFlow(sessionId, qn, inputs, true);

if (running == null) {
msg = "Flow " + qn + " does not exist or cannot be launched from an application";
} else if (running) {
msg = "Flow is already in course";
} else {
return new Pair<>(bridge.getTriggerUrl(), null);
}
}

} catch (Exception e) {
msg = e.getMessage();
scriptLogger.error(msg, e);
}
return new Pair<>(null, msg);

}

private User extractUser(String userId) {

UserService userService = CdiUtil.bean(UserService.class);
List<User> matchingUsers = userService.getUsersByAttribute(finishIdAttr, userId, true, 2);
int matches = matchingUsers.size();

if (matches != 1) {
if (matches == 0) {
scriptLogger.warn("No user matches the required condition: {}={}", finishIdAttr, userId);
} else {
scriptLogger.warn("Several users match the required condition: {}={}", finishIdAttr, userId);
}

return null;
}
return matchingUsers.get(0);

}

@Override
public boolean authorize(Object scriptContext) {

ExternalScriptContext context = (ExternalScriptContext) scriptContext;

if (!CdiUtil.bean(FlowUtils.class).serviceEnabled())
return makeUnexpectedError(context, null, "Agama engine is disabled");

if (!context.getAuthzRequest().isUseAuthorizationChallengeSession())
return makeMissingParamError(context, "Please set 'use_auth_session=true' in your request");

ServletRequest servletRequest = context.getHttpRequest();
AuthorizationChallengeSession deviceSessionObject = context.getAuthzRequest().getAuthorizationChallengeSessionObject();

boolean noSO = deviceSessionObject == null;
scriptLogger.debug("There IS{} device session object", noSO ? " NO" : "");

Map<String, String> deviceSessionObjectAttrs = null;
String sessionId = null, url = null, payload = null;

if (noSO) {

String fname = servletRequest.getParameter("flow_name");
if (fname == null)
return makeMissingParamError(context, "Parameter 'flow_name' missing in request");

deviceSessionObject = deviceSessionService.newAuthorizationChallengeSession();
sessionId = deviceSessionObject.getId();

Pair<String, String> pre = prepareFlow(sessionId, fname);
url = pre.getFirst();

if (url == null) return makeUnexpectedError(context, deviceSessionObject, pre.getSecond());

deviceSessionObjectAttrs = deviceSessionObject.getAttributes().getAttributes();
deviceSessionObjectAttrs.put("url", url);
deviceSessionObjectAttrs.put("client_id", servletRequest.getParameter("client_id"));
deviceSessionObjectAttrs.put("acr_values", servletRequest.getParameter("acr_values"));
deviceSessionObjectAttrs.put("scope", servletRequest.getParameter("scope"));

deviceSessionService.persist(deviceSessionObject);

} else {
sessionId = deviceSessionObject.getId();
deviceSessionObjectAttrs = deviceSessionObject.getAttributes().getAttributes();
String userId = deviceSessionObjectAttrs.get("userId");

if (userId != null) {
User user = extractUser(userId);

if (user == null)
return makeUnexpectedError(context, deviceSessionObject, "Unable to determine identity of user");

context.getExecutionContext().setUser(user);
scriptLogger.debug("User {} is authenticated successfully", user.getUserId());

entryManager.remove(deviceSessionObject.getDn(), AuthorizationChallengeSession.class);
return true;
}

url = deviceSessionObjectAttrs.get("url");
if (url == null)
return makeUnexpectedError(context, deviceSessionObject, "Illegal state - url is missing in device session object");

payload = servletRequest.getParameter("data");
if (payload == null)
return makeMissingParamError(context, "Parameter 'data' missing in request");
}

Pair<MiniBrowser.Outcome, JSONObject> p = miniBrowser.move(sessionId, url, payload);
MiniBrowser.Outcome result = p.getFirst();
String strRes = result.toString();
JSONObject jres = p.getSecond();

if (result == CLIENT_ERROR || result == ENGINE_ERROR) {
return makeError(context, deviceSessionObject, true, strRes, jres, 500);

} else if (result == FLOW_PAUSED){
url = p.getSecond().remove(MiniBrowser.FLOW_PAUSED_URL_KEY).toString();
deviceSessionObjectAttrs.put("url", url);
deviceSessionService.merge(deviceSessionObject);

scriptLogger.info("Next url will be {}", url);
return makeError(context, deviceSessionObject, false, strRes, jres, 401);

} else if (result == FLOW_FINISHED) {

try {
AgamaPersistenceService aps = CdiUtil.bean(AgamaPersistenceService.class);
FlowStatus fs = aps.getFlowStatus(sessionId);

if (fs == null)
return makeUnexpectedError(context, deviceSessionObject, "Flow is not running");

FlowResult fr = fs.getResult();
if (fr == null)
return makeUnexpectedError(context, deviceSessionObject,
"The flow finished but the resulting outcome was not found");

JSONObject jobj = new JSONObject(fr);
jobj.remove("aborted"); //just to avoid confusions and questions from users

if (!fr.isSuccess()) {
scriptLogger.info("Flow DID NOT finished successfully");
return makeError(context, deviceSessionObject, true, strRes, jobj, 401);
}

String userId = Optional.ofNullable(fr.getData()).map(d -> d.get("userId"))
.map(Object::toString).orElse(null);

if (userId == null)
return makeUnexpectedError(context, deviceSessionObject, "Unable to determine identity of user. " +
"No userId provided in flow result");

deviceSessionObjectAttrs.put("userId", userId);
deviceSessionService.merge(deviceSessionObject);
aps.terminateFlow(sessionId);

return makeError(context, deviceSessionObject, false, strRes, jobj, 401);

} catch (IOException e) {
return makeUnexpectedError(context, deviceSessionObject, e.getMessage());
}
} else {
return makeUnexpectedError(context, deviceSessionObject, "Illegal state - unexpected outcome " + strRes);
}

}

@Override
public boolean init(Map<String, SimpleCustomProperty> configurationAttributes) {
scriptLogger.info("Initialized Agama AuthorizationChallenge Java custom script");
return true;
}

@Override
public boolean init(CustomScript customScript, Map<String, SimpleCustomProperty> configurationAttributes) {

scriptLogger.info("Initialized Agama AuthorizationChallenge Java custom script.");
finishIdAttr = null;
String name = "finish_userid_db_attribute";
SimpleCustomProperty prop = configurationAttributes.get(name);

if (prop != null) {
finishIdAttr = prop.getValue2();
if (StringHelper.isEmpty(finishIdAttr)) {
finishIdAttr = null;
}
}

if (finishIdAttr == null) {
scriptLogger.info("Property '{}' is missing value", name);
return false;
}
scriptLogger.info("DB attribute '{}' will be used to map the identity of userId passed "+
"in Finish directives (if any)", finishIdAttr);

entryManager = CdiUtil.bean(PersistenceEntryManager.class);
deviceSessionService = CdiUtil.bean(AuthorizationChallengeSessionService.class);
miniBrowser = new MiniBrowser(CdiUtil.bean(AppConfiguration.class).getIssuer());
return true;

}

@Override
public boolean destroy(Map<String, SimpleCustomProperty> configurationAttributes) {
scriptLogger.info("Destroyed Agama AuthorizationChallenge Java custom script.");
return true;
}

@Override
public int getApiVersion() {
return 11;
}

@Override
public Map<String, String> getAuthenticationMethodClaims(Object context) {
return Map.of();
}

}
14 changes: 9 additions & 5 deletions jans-auth-server/agama/engine/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,15 @@
<artifactId>zip4j</artifactId>
<version>2.11.5</version>
</dependency>

<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>

<!-- TESTS -->
<dependency>
Expand All @@ -237,11 +246,6 @@
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<scope>test</scope>
</dependency>
<!-- Needed for htmlunit <-> log4j2 intergration -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public Boolean prepareFlow(String sessionId, String qname, String jsonInput, boo
}
if (st == null) {

int timeout = aps.getEffectiveFlowTimeout(qname);
int timeout = aps.getEffectiveFlowTimeout(qname, nativeClient);
if (timeout <= 0) throw new Exception("Flow timeout negative or zero. " +
"Check your AS configuration or flow definition");
long expireAt = System.currentTimeMillis() + 1000L * timeout;
Expand Down
Loading

0 comments on commit 856f9fe

Please sign in to comment.