Skip to content

Commit bd3e3e4

Browse files
OPENIG-9667 Investigation into OB FT failures (#626)
- Rework of RepoApiClient to operate in an async way as this route/script is frequently called. - Rework RepoUser to behave async too. - Rework AddDetachedSig to behave async too
1 parent d355c8e commit bd3e3e4

File tree

3 files changed

+306
-184
lines changed

3 files changed

+306
-184
lines changed
Lines changed: 91 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import static org.forgerock.http.protocol.Response.newResponsePromise
2+
import static org.forgerock.http.protocol.Status.OK
3+
import static org.forgerock.http.protocol.Status.INTERNAL_SERVER_ERROR
4+
import static org.forgerock.secrets.Purpose.purposeOf
5+
16
import org.forgerock.secrets.keys.SigningKey
27
import org.forgerock.json.jose.jws.SigningManager
38
import org.forgerock.json.jose.jwt.JwtClaimsSet
49
import org.forgerock.json.jose.builders.JwtBuilderFactory
10+
import org.forgerock.json.jose.exceptions.InvalidJwtException
511
import org.forgerock.secrets.Purpose
612
import org.forgerock.json.jose.jws.JwsAlgorithm
713
import org.forgerock.http.protocol.Status
814

15+
import org.forgerock.util.promise.NeverThrowsException
16+
import org.forgerock.json.JsonValue
17+
918
/**
1019
* Add detached signature to HTTP response
1120
*
@@ -24,77 +33,91 @@ IAT_CRIT_CLAIM = "http://openbanking.org.uk/iat"
2433
ISS_CRIT_CLAIM = "http://openbanking.org.uk/iss"
2534
TAN_CRIT_CLAIM = "http://openbanking.org.uk/tan"
2635

27-
next.handle(context, request).thenOnResult({ response ->
28-
logger.debug(SCRIPT_NAME + "Running...")
29-
logger.debug(SCRIPT_NAME + "routeArgSecretId: " + routeArgSecretId)
30-
logger.debug(SCRIPT_NAME + "routeArgKid: " + routeArgKid)
36+
return filter(context, request, next)
3137

32-
JwsAlgorithm signAlgorithm = JwsAlgorithm.parseAlgorithm(routeArgAlgorithm)
33-
logger.debug(SCRIPT_NAME + "Algorithm initialised: " + signAlgorithm)
38+
/**
39+
* Filter implementation to add the detached signature.
40+
* @param context the Context
41+
* @param request the request
42+
* @param next the next Handler
43+
* @return Promise of a Response containing the API client
44+
*/
45+
Promise<Response, NeverThrowsException> filter(final Context context,
46+
final Request request,
47+
final Handler next) {
48+
return next.handle(context, request).thenOnResult({ response ->
49+
logger.debug(SCRIPT_NAME + "Running... routeArgSecretId: {}, routeArgKid: {}", routeArgSecretId, routeArgKid)
50+
return getJwtClaimsSet(response)
51+
.thenAsync(jwtClaimSet -> buildEncodedJwt(jwtClaimSet),
52+
exception -> newResponsePromise(fail(INTERNAL_SERVER_ERROR, exception.getMessage())))
53+
.then(encodedJwt -> addDetachedSignature(encodedJwt, response),
54+
exception -> {
55+
return fail(INTERNAL_SERVER_ERROR, "Error creating signature JWT")
56+
})
57+
.thenCatch(exception -> fail(INTERNAL_SERVER_ERROR, exception.getMessage()))
58+
})
59+
}
3460

61+
private Promise<JwtClaimsSet, Exception> getJwtClaimsSet(Response response) {
62+
if (response.getEntity().isRawContentEmpty()) {
63+
// We get content empty on submit file payment API
64+
logger.debug("Response entity has raw content")
65+
return newResultPromise(new JwtClaimsSet())
66+
}
67+
return response.getEntity()
68+
.getJsonAsync()
69+
.then(jsonContent -> new JsonValue(jsonContent).expect(Map.class))
70+
.then(jsonContent -> new JwtClaimsSet(jsonContent.asMap()),
71+
exception -> {
72+
throw new IOException("Evaluation response has malformed response JSON");
73+
})
74+
}
75+
76+
private Promise<String, Exception> buildEncodedJwt(JwtClaimsSet jwtClaimsSet) {
77+
logger.debug(SCRIPT_NAME + "Building encoded JWT for claims set: {}", jwtClaimsSet)
3578
Purpose<SigningKey> purpose = new JsonValue(routeArgSecretId).as(purposeOf(SigningKey.class))
36-
3779
SigningManager signingManager = new SigningManager(routeArgSecretsProvider)
38-
39-
signingManager.newSigningHandler(purpose).then({ signingHandler ->
80+
return signingManager.newSigningHandler(purpose).then({ signingHandler ->
4081
logger.debug(SCRIPT_NAME + "Building of the JWT started")
41-
42-
JwtClaimsSet jwtClaimsSet
43-
// We get content empty on submit file payment API
44-
if (response.getEntity().isRawContentEmpty()) {
45-
jwtClaimsSet = new JwtClaimsSet()
46-
} else {
47-
jwtClaimsSet = new JwtClaimsSet(response.getEntity().getJson())
48-
}
49-
logger.debug(SCRIPT_NAME + "jwtClaimsSet: " + jwtClaimsSet)
50-
51-
List<String> critClaims = new ArrayList<String>();
52-
critClaims.add(IAT_CRIT_CLAIM);
53-
critClaims.add(ISS_CRIT_CLAIM);
54-
critClaims.add(TAN_CRIT_CLAIM);
55-
56-
String jwt
57-
try {
58-
jwt = new JwtBuilderFactory()
59-
.jws(signingHandler)
60-
.headers()
61-
.alg(signAlgorithm)
62-
.kid(routeArgKid)
63-
.header(IAT_CRIT_CLAIM, System.currentTimeMillis() / 1000)
64-
.header(ISS_CRIT_CLAIM, obAspspOrgId) // For an ASPSP the ISS_CRIT_CLAIM is the OB Issued orgId
65-
.header(TAN_CRIT_CLAIM, routeArgTrustedAnchor)
66-
.crit(critClaims)
67-
.done()
68-
.claims(jwtClaimsSet)
69-
.build()
70-
} catch (java.lang.Exception e) {
71-
logger.debug(SCRIPT_NAME + "Error building JWT: " + e)
72-
}
73-
74-
logger.debug(SCRIPT_NAME + "Signed JWT [" + jwt + "]")
75-
76-
if (jwt == null || jwt.length() == 0) {
77-
message = "Error creating signature JWT"
78-
logger.error(SCRIPT_NAME + message)
79-
response.status = Status.INTERNAL_SERVER_ERROR
80-
response.entity = "{ \"error\":\"" + message + "\"}"
81-
return response
82-
}
83-
84-
String[] jwtElements = jwt.split("\\.")
85-
86-
if (jwtElements.length != 3) {
87-
message = "Wrong number of dots on outbound detached signature"
88-
logger.error(SCRIPT_NAME + message)
89-
response.status = Status.INTERNAL_SERVER_ERROR
90-
response.entity = "{ \"error\":\"" + message + "\"}"
91-
return response
92-
}
93-
94-
String detachedSig = jwtElements[0] + ".." + jwtElements[2]
95-
logger.debug(SCRIPT_NAME + "Adding detached signature [" + detachedSig + "]")
96-
97-
response.getHeaders().add(routeArgHeaderName, detachedSig);
98-
return response
82+
List<String> critClaims = List.of(IAT_CRIT_CLAIM, ISS_CRIT_CLAIM, TAN_CRIT_CLAIM);
83+
JwsAlgorithm signAlgorithm = JwsAlgorithm.parseAlgorithm(routeArgAlgorithm)
84+
logger.debug(SCRIPT_NAME + "Algorithm initialised: " + signAlgorithm)
85+
String encodedJwt = new JwtBuilderFactory()
86+
.jws(signingHandler)
87+
.headers()
88+
.alg(signAlgorithm)
89+
.kid(routeArgKid)
90+
.header(IAT_CRIT_CLAIM, System.currentTimeMillis() / 1000)
91+
// For an ASPSP the ISS_CRIT_CLAIM is the OB Issued orgId
92+
.header(ISS_CRIT_CLAIM, obAspspOrgId)
93+
.header(TAN_CRIT_CLAIM, routeArgTrustedAnchor)
94+
.crit(critClaims)
95+
.done()
96+
.claims(jwtClaimsSet)
97+
.build()
98+
return encodedJwt
9999
})
100-
})
100+
}
101+
102+
private Response addDetachedSignature(String encodedJwt, Response response) {
103+
logger.debug(SCRIPT_NAME + "Adding detached signature - encodedJwt {}", encodedJwt)
104+
String[] jwtElements = encodedJwt.split("\\.")
105+
if (jwtElements.length != 3) {
106+
message = "Wrong number of dots on outbound detached signature"
107+
logger.error(SCRIPT_NAME + message)
108+
throw new InvalidJwtException(message) as Throwable
109+
}
110+
// Create JWT with detached sig
111+
String detachedSig = "%s..%s".formatted(jwtElements[0], jwtElements[2])
112+
logger.debug(SCRIPT_NAME + "Adding detached signature [{}]", detachedSig)
113+
response.getHeaders().add(routeArgHeaderName, detachedSig);
114+
return response
115+
}
116+
117+
private Response fail(Status errorStatus, String errorMessage) {
118+
Response response = new Response(OK)
119+
response.headers['Content-Type'] = "application/json"
120+
response.status = errorStatus
121+
response.entity = json(object(field("error", errorMessage)))
122+
return response
123+
}

config/7.3.0/securebanking/ig/scripts/groovy/RepoApiClient.groovy

Lines changed: 98 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,116 @@
1-
import groovy.json.JsonOutput
1+
import static org.forgerock.http.protocol.Response.newResponsePromise
2+
import static org.forgerock.http.protocol.Status.OK
3+
import static org.forgerock.json.JsonValue.field
4+
import static org.forgerock.json.JsonValue.json
5+
import static org.forgerock.json.JsonValue.object
6+
import static org.forgerock.util.Closeables.closeSilently
27

8+
import org.forgerock.util.promise.NeverThrowsException
9+
import org.forgerock.json.JsonValue
10+
11+
/**
12+
* Scripted Handler implementation to fetch an API client from the repo.
13+
*/
14+
15+
// FAPI logging
316
def fapiInteractionId = request.getHeaders().getFirst("x-fapi-interaction-id");
417
if(fapiInteractionId == null) fapiInteractionId = "No x-fapi-interaction-id";
518
SCRIPT_NAME = "[RepoApiClient] (" + fapiInteractionId + ") - ";
619
logger.debug(SCRIPT_NAME + "Running...")
720

8-
// Fetch the API Client from IDM
9-
Request apiClientRequest = new Request();
10-
apiClientRequest.setMethod('GET');
11-
12-
// response object
13-
response = new Response(Status.OK)
14-
response.headers['Content-Type'] = "application/json"
21+
return handle(context, request)
22+
23+
/**
24+
* Fetch the API client from the repo.
25+
* @param unusedContext Context is unused
26+
* @param request Request to obtain API client
27+
* @return Promise of a Response containing the API client
28+
*/
29+
Promise<Response, NeverThrowsException> handle(final Context unusedContext, final Request request) {
30+
def apiClientId = extractApiClientId(request)
31+
if (apiClientId == null) {
32+
message = "Can't parse api client ID from inbound request"
33+
logger.error(SCRIPT_NAME + message)
34+
return newResponsePromise(fail(BAD_REQUEST, message))
35+
}
1536

16-
def splitUri = request.uri.path.split("/")
37+
// Fetch the API Client from the repo (IDM)
38+
Request apiClientRequest = new Request();
39+
def apiClientUri = routeArgIdmBaseUri + "/openidm/managed/" + routeArgObjApiClient + "/" + apiClientId
40+
apiClientRequest.setMethod('GET');
41+
apiClientRequest.setUri(apiClientUri)
1742

18-
if (splitUri.length == 0) {
19-
message = "Can't parse api client ID from inbound request"
20-
logger.error(SCRIPT_NAME + message)
21-
response.status = Status.BAD_REQUEST
22-
response.entity = "{ \"error\":\"" + message + "\"}"
23-
return response
43+
logger.debug(SCRIPT_NAME + "Obtaining API client data from repo {}", apiClientUri)
44+
return http.send(apiClientRequest)
45+
.thenAlways(() -> closeSilently(apiClientRequest))
46+
.thenAsync(apiClientResponse -> handleApiClientResponse(apiClientResponse))
2447
}
2548

26-
def apiClientId = splitUri[splitUri.length - 1];
27-
28-
logger.debug(SCRIPT_NAME + "Looking up API Client {}",apiClientId)
29-
30-
apiClientRequest.setUri(routeArgIdmBaseUri + "/openidm/managed/" + routeArgObjApiClient + "/" + apiClientId)
31-
32-
http.send(apiClientRequest).then(apiClientResponse -> {
33-
apiClientRequest.close()
34-
logger.debug(SCRIPT_NAME + "Back from IDM")
49+
private String extractApiClientId(Request request) {
50+
// Extract the API client ID from the REST request
51+
def splitUri = request.uri.path.split("/")
52+
if (splitUri.length == 0) {
53+
return null
54+
}
55+
def apiClientId = splitUri[splitUri.length - 1]
56+
logger.debug(SCRIPT_NAME + "Looking up API Client {}", apiClientId)
57+
return apiClientId
58+
}
3559

36-
def apiClientResponseStatus = apiClientResponse.getStatus();
60+
private Promise<Response, NeverThrowsException> handleApiClientResponse(Response apiClientResponse) {
61+
logger.debug(SCRIPT_NAME + "Handling API client response")
62+
return processResponseContent(apiClientResponse)
63+
.thenAlways(() -> closeSilently(apiClientResponse))
64+
.then(apiClientResponseJson -> transformApiClientResponse(apiClientResponseJson),
65+
exception -> {
66+
fail(apiClientResponseStatus, exception.getMessage())
67+
})
68+
}
3769

38-
if (apiClientResponseStatus != Status.OK) {
39-
message = "Failed to get API Client details"
40-
logger.error(message)
41-
response.status = apiClientResponseStatus
42-
response.entity = "{ \"error\":\"" + message + "\"}"
43-
return response
70+
private Promise<JsonValue, Exception> processResponseContent(final Response apiClientResponse) {
71+
if (!(OK.equals(apiClientResponse.getStatus()))) {
72+
logger.error("Unable to communicate with API client endpoint - status code {}",
73+
apiClientResponse.getStatus().getCode())
74+
return newExceptionPromise(
75+
new IOException("Failed to get API Client details - problem communicating with repo"))
4476
}
77+
ContentTypeHeader contentTypeHeader = ContentTypeHeader.valueOf(apiClientResponse)
78+
String contentType = contentTypeHeader != null ? contentTypeHeader.getType() : null
79+
if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) {
80+
logger.error("API client endpoint response has unexpected content-type {}", contentType)
81+
return newExceptionPromise(
82+
new IOException("Failed to get API Client details - unexpected content " + contentType))
83+
}
84+
return getJsonContentAsync(apiClientResponse)
85+
}
4586

46-
def apiClientResponseContent = apiClientResponse.getEntity();
47-
def apiClientResponseObject = apiClientResponseContent.getJson();
48-
49-
def responseObj = [
50-
"id": apiClientResponseObject.id,
51-
"name": apiClientResponseObject.name,
52-
"officialName": apiClientResponseObject.name,
53-
"oauth2ClientId": apiClientResponseObject.oauth2ClientId,
54-
"logoUri": apiClientResponseObject.logoUri
55-
]
87+
private static Promise<JsonValue, Exception> getJsonContentAsync(final Response response) {
88+
return response.getEntity()
89+
.getJsonAsync()
90+
.then(jsonContent -> new JsonValue(jsonContent).expect(Map.class))
91+
.thenCatch(exception -> {
92+
throw new IOException("Evaluation response has malformed response JSON");
93+
})
94+
}
5695

57-
def responseJson = JsonOutput.toJson(responseObj);
58-
logger.debug(SCRIPT_NAME + "Final JSON " + responseJson)
96+
private Response transformApiClientResponse(JsonValue apiClientResponseJson) {
97+
JsonValue transformedResponseJson = json(object(
98+
field("id", apiClientResponseJson.get("id")),
99+
field("name", apiClientResponseJson.get("name")),
100+
field("officialName", apiClientResponseJson.get("name")),
101+
field("oauth2ClientId", apiClientResponseJson.get("oauth2ClientId")),
102+
field("logoUri", apiClientResponseJson.get("logoUri"))))
103+
logger.debug(SCRIPT_NAME + "Transformed JSON {}", transformedResponseJson)
104+
Response transformedResponse = new Response(Status.OK)
105+
transformedResponse.getEntity().setJson(transformedResponseJson);
106+
return transformedResponse
107+
}
59108

60-
response.entity = responseJson;
109+
private Response fail(Status errorStatus, String errorMessage) {
110+
Response response = new Response(OK)
111+
response.headers['Content-Type'] = "application/json"
112+
response.status = errorStatus
113+
response.entity = json(object(field("error", errorMessage)))
61114
return response
115+
}
62116

63-
}).then(response -> { return response })

0 commit comments

Comments
 (0)