Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow integration of Agama flows into the authz challenge enpoint #10587

Merged
merged 3 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading