How to emulate CIBA with ForgeRock Access Manager

We had a need to create a Customer Service Representative Demo to showcase how to use ForgeRock Access Manager aka PingAM, and we followed the guide at https://backstage.forgerock.com/docs/idcloud/latest/am-oidc1/openid-connect-backchannel-request-flow.html to configure our instance. However our problem is that we dont have access to configure the Push Notification Service, so what we decided to do was mock those interfaces using Scripted Decision Nodes.

Here are the steps

Create Scripted Decision Nodes with Next GenerationJavaScript.

We created 2 Scripts.

  • CIBA-PushSender
 logger.warn("CIBA-PushSender: {}", "Sent"); action.goTo("Sent");
Enter fullscreen mode
Exit fullscreen mode
  • CIBA-PushResultVerifierNode
 logger.warn("CIBA-PushResultVerifierNode: {}", "Success"); action.goTo("Success");
Enter fullscreen mode
Exit fullscreen mode

Created a cibaInit Journey

A journey named cibaInit was created that replicated the example provided by ForgeRock, but using Scripted Decision Nodes

Image description

NodeConfiguration
Username CollectorImage description
Push SenderImage description
Pushed Result Verifier NodeImage description
Polling Wait NodeImage description

Created a BackChannel Application

Using https://mkjwk.org/ we used the following config

Image description

This was then used to configure an Application called BackChannel

Image description

Testing

Now we have all the basics in place we start testing the solution. In essence everything in the cibaInit journey will just passthrough to get the Access Token, so there should be no delays or issues.

Request a auth-req-id

This requires a JWt Signed by the keys supplied to FRAM.
In Postman we use the following Pre-request script

var jwtPrivateKey = `-----BEGIN PRIVATE KEY-----
....
-----END PRIVATE KEY-----`; // Set headers for JWT
var header = { 'alg': 'ES256', 'typ': 'JWT', 'kid': 'myCibaKey'
}; // Prepare timestamp in seconds
var currentTimestamp = Math.floor(Date.now() / 1000); var payload = { "aud": "https://xxxx.xxxx.darkedges.com/openam/oauth2", "binding_message": "Allow ExampleBank to transfer £50 from 'Main' to 'Savings'? (EB-0246326)", "acr_values": "email", "exp": currentTimestamp + 60 * 5, "iss": "BackChannel", "login_hint": "<email address of user>", "scope": "openid profile"
}; function generateJwt() { eval(pm.globals.get('jsrsasign-js')); // import javascript jsrsasign var sHeader = JSON.stringify(header); var sPayload = JSON.stringify(payload); var signedToken = KJUR.jws.JWS.sign(header.alg, sHeader, sPayload, jwtPrivateKey); pm.environment.set('signed-jwt', signedToken); console.log('jwt', signedToken);
} var navigator = {}; // fake a navigator object for the lib
var window = {}; // fake a window object for the lib if (pm.globals.has('jsrsasign-js')) generateJwt();
else pm.sendRequest( 'https://kjur.github.io/jsrsasign/jsrsasign-all-min.js', function (err, res) { if (err) { console.log(err); } else { pm.globals.set('jsrsasign-js', res.text()); generateJwt(); }});
Enter fullscreen mode
Exit fullscreen mode

Then it creates the following request

curl --location curl --location 'https://xxxx.xxxx.darkedges.com/openam/oauth2/bc-authorize' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: ••••••' \
--data-urlencode 'request=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im15Q2liYUtleSJ9.eyJhdWQiOiJodHRwczovL3h4eHgueHh4eC5kYXJrZWRnZXMuY29tL29wZW5hbS9vYXV0aDIiLCJiaW5kaW5nX21lc3NhZ2UiOiJBbGxvdyBFeGFtcGxlQmFuayB0byB0cmFuc2ZlciDCozUwIGZyb20gJ01haW4nIHRvICdTYXZpbmdzJz8gKEVCLTAyNDYzMjYpIiwiYWNyX3ZhbHVlcyI6ImVtYWlsIiwiZXhwIjoxNzIxMDg1ODQ2LCJpc3MiOiJCYWNrQ2hhbm5lbCIsImxvZ2luX2hpbnQiOiI8ZW1haWwgYWRkcmVzcyBvZiB1c2VyPiIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUifQ.BA-FkcK4c6J8Heh7dxT6RJLmW6HTe4FZ7b2KSRcFiOFQhp18y_9Taquj0LRNcDBz8w_1qHejvfEAWCEtv8ZosQ'
Enter fullscreen mode
Exit fullscreen mode

Which returns

{ "auth_req_id": "TNrRNWXB86jFthHehaBfyinMpYI", "expires_in": 600, "interval": 2
}
Enter fullscreen mode
Exit fullscreen mode

Get Access Token

Now we can get the Access Token for the request.

curl --location 'https://xxxx.xxxx.darkedges.com/openam/oauth2/access_token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: ••••••' \
--data-urlencode 'auth_req_id=TNrRNWXB86jFthHehaBfyinMpYI' \
--data-urlencode 'grant_type=urn:openid:params:grant-type:ciba'
Enter fullscreen mode
Exit fullscreen mode

which returns

{ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIodXNyITxlbWFpbCBhZGRyZXNzIG9mIHVzZXI-KSIsImN0cyI6Ik9BVVRIMl9TVEFURUxFU1NfR1JBTlQiLCJhdWRpdFRyYWNraW5nSWQiOiJjMGZlNzcwMi02NTE1LTRhMjgtYjFkNi1kNjMxZTRhYjcxNjAtMTM5MjIiLCJzdWJuYW1lIjoiPGVtYWlsIGFkZHJlc3Mgb2YgdXNlcj4iLCJpc3MiOiJodHRwczovL3h4eHgueHh4eC5kYXJrZWRnZXMuY29tL29wZW5hbS9vYXV0aDIiLCJ0b2tlbk5hbWUiOiJhY2Nlc3NfdG9rZW4iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiYXV0aEdyYW50SWQiOiJzVWZHUE1ZMUpjV3J6NVVsWVhHTnhnTGs5c28iLCJhdWQiOiJCYWNrQ2hhbm5lbCIsIm5iZiI6MTcyMTA4NTYzOCwiZ3JhbnRfdHlwZSI6InVybjpvcGVuaWQ6cGFyYW1zOmdyYW50LXR5cGU6Y2liYSIsInNjb3BlIjpbIm9wZW5pZCIsInByb2ZpbGUiXSwiYXV0aF90aW1lIjoxNzIxMDg1NjM4LCJyZWFsbSI6Ii9jb25uZWN0aWQiLCJleHAiOjE3MjEwODkyMzgsImlhdCI6MTcyMTA4NTYzOCwiZXhwaXJlc19pbiI6MzYwMCwianRpIjoiQ1dPTXh5UEpxUzdFR2huNWxzemJjYWF1SlVrIn0.eVG8sBmJY6BvROzDTOjKfGTXzK8SdcYMWbLanrLCgUk", "scope": "openid profile", "id_token": "eyJ0eXAiOiJKV1QiLCJraWQiOiI4dEZoOURsSnI4SU56OTl6VjBCU094UkdieE09IiwiYWxnIjoiSFMyNTYifQ.eyJhdF9oYXNoIjoiMkJWeDJYZ2Y1UmZJdlhnaEpWVlVpQSIsInN1YiI6Iih1c3IhPGVtYWlsIGFkZHJlc3Mgb2YgdXNlcj4pIiwiYXVkaXRUcmFja2luZ0lkIjoiYzBmZTc3MDItNjUxNS00YTI4LWIxZDYtZDYzMWU0YWI3MTYwLTEzOTIzIiwic3VibmFtZSI6IjxlbWFpbCBhZGRyZXNzIG9mIHVzZXI-IiwiaXNzIjoiaHR0cHM6Ly94eHh4Lnh4eHguZGFya2VkZ2VzLmNvbS9vcGVuYW0vb2F1dCIsInRva2VuTmFtZSI6ImlkX3Rva2VuIiwiZ2l2ZW5fbmFtZSI6IkRlbW8iLCJhdWQiOiJCYWNrQ2hhbm5lbCIsImF6cCI6IkJhY2tDaGFubmVsIiwiYXV0aF90aW1lIjoxNzIxMDg1NjM4LCJuYW1lIjoiRGVtbyBVc2VyIiwicmVhbG0iOiIvY29ubmVjdGlkIiwiZXhwIjoxNzIxMDg5MjM4LCJ0b2tlblR5cGUiOiJKV1RUb2tlbiIsImlhdCI6MTcyMTA4NTYzOCwiZmFtaWx5X25hbWUiOiJVc2VyIn0.7YxVoJ7s-enmQrbNduOQ5Aq57LAi5XVeHApvlzAOJnU", "token_type": "Bearer", "expires_in": 3599
}
Enter fullscreen mode
Exit fullscreen mode

Introspect the Access Token

We can now finally introspect the Access Token

curl --location 'https://fram.connectid.darkedges.com/openam/oauth2/introspect' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: ••••••' \
--data-urlencode 'token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIodXNyITxlbWFpbCBhZGRyZXNzIG9mIHVzZXI-KSIsImN0cyI6Ik9BVVRIMl9TVEFURUxFU1NfR1JBTlQiLCJhdWRpdFRyYWNraW5nSWQiOiJjMGZlNzcwMi02NTE1LTRhMjgtYjFkNi1kNjMxZTRhYjcxNjAtMTM5MjIiLCJzdWJuYW1lIjoiPGVtYWlsIGFkZHJlc3Mgb2YgdXNlcj4iLCJpc3MiOiJodHRwczovL3h4eHgueHh4eC5kYXJrZWRnZXMuY29tL29wZW5hbS9vYXV0aDIiLCJ0b2tlbk5hbWUiOiJhY2Nlc3NfdG9rZW4iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiYXV0aEdyYW50SWQiOiJzVWZHUE1ZMUpjV3J6NVVsWVhHTnhnTGs5c28iLCJhdWQiOiJCYWNrQ2hhbm5lbCIsIm5iZiI6MTcyMTA4NTYzOCwiZ3JhbnRfdHlwZSI6InVybjpvcGVuaWQ6cGFyYW1zOmdyYW50LXR5cGU6Y2liYSIsInNjb3BlIjpbIm9wZW5pZCIsInByb2ZpbGUiXSwiYXV0aF90aW1lIjoxNzIxMDg1NjM4LCJyZWFsbSI6Ii9jb25uZWN0aWQiLCJleHAiOjE3MjEwODkyMzgsImlhdCI6MTcyMTA4NTYzOCwiZXhwaXJlc19pbiI6MzYwMCwianRpIjoiQ1dPTXh5UEpxUzdFR2huNWxzemJjYWF1SlVrIn0.eVG8sBmJY6BvROzDTOjKfGTXzK8SdcYMWbLanrLCgUk
Enter fullscreen mode
Exit fullscreen mode

returns

{ "active": true, "scope": "openid profile", "realm": "/connectid", "client_id": "BackChannel", "user_id": "<email address of user>", "username": "<email address of user>", "token_type": "Bearer", "exp": 1721089238, "sub": "([email protected])", "iss": "https://xxxx.xxxx.darkedges.com/openam/oauth2", "subname": "<email address of user>", "authGrantId": "sUfGPMY1JcWrz5UlYXGNxgLk9so", "auditTrackingId": "c0fe7702-6515-4a28-b1d6-d631e4ab7160-13922", "expires_in": 3356
}
Enter fullscreen mode
Exit fullscreen mode

Final Thoughts

For a Proof of Concept this works for us and should give you the basics for other projects or to demonstrate how it works.

You can download the Journey and OAuth2Client Amster from https://gist.github.com/darkedges/e42a70b4c5a76e817154caafb5b7eb92


Discover more from Coursity

Subscribe to get the latest posts sent to your email.

Leave a Comment

Your email address will not be published. Required fields are marked *

How to emulate CIBA with ForgeRock Access Manager

We had a need to create a Customer Service Representative Demo to showcase how to use ForgeRock Access Manager aka PingAM, and we followed the guide at https://backstage.forgerock.com/docs/idcloud/latest/am-oidc1/openid-connect-backchannel-request-flow.html to configure our instance. However our problem is that we dont have access to configure the Push Notification Service, so what we decided to do was mock those interfaces using Scripted Decision Nodes.

Here are the steps

Create Scripted Decision Nodes with Next GenerationJavaScript.

We created 2 Scripts.

  • CIBA-PushSender
 logger.warn("CIBA-PushSender: {}", "Sent"); action.goTo("Sent");
Enter fullscreen modeExit fullscreen mode
  • CIBA-PushResultVerifierNode
 logger.warn("CIBA-PushResultVerifierNode: {}", "Success"); action.goTo("Success");
Enter fullscreen modeExit fullscreen mode

Created a cibaInit Journey

A journey named cibaInit was created that replicated the example provided by ForgeRock, but using Scripted Decision Nodes

Image description

NodeConfiguration
Username CollectorImage description
Push SenderImage description
Pushed Result Verifier NodeImage description
Polling Wait NodeImage description

Created a BackChannel Application

Using https://mkjwk.org/ we used the following config

Image description

This was then used to configure an Application called BackChannel

Image description

Testing

Now we have all the basics in place we start testing the solution. In essence everything in the cibaInit journey will just passthrough to get the Access Token, so there should be no delays or issues.

Request a auth-req-id

This requires a JWt Signed by the keys supplied to FRAM.
In Postman we use the following Pre-request script

var jwtPrivateKey = `-----BEGIN PRIVATE KEY-----
....
-----END PRIVATE KEY-----`; // Set headers for JWT
var header = { 'alg': 'ES256', 'typ': 'JWT', 'kid': 'myCibaKey'
}; // Prepare timestamp in seconds
var currentTimestamp = Math.floor(Date.now() / 1000); var payload = { "aud": "https://xxxx.xxxx.darkedges.com/openam/oauth2", "binding_message": "Allow ExampleBank to transfer £50 from 'Main' to 'Savings'? (EB-0246326)", "acr_values": "email", "exp": currentTimestamp + 60 * 5, "iss": "BackChannel", "login_hint": "<email address of user>", "scope": "openid profile"
}; function generateJwt() { eval(pm.globals.get('jsrsasign-js')); // import javascript jsrsasign var sHeader = JSON.stringify(header); var sPayload = JSON.stringify(payload); var signedToken = KJUR.jws.JWS.sign(header.alg, sHeader, sPayload, jwtPrivateKey); pm.environment.set('signed-jwt', signedToken); console.log('jwt', signedToken);
} var navigator = {}; // fake a navigator object for the lib
var window = {}; // fake a window object for the lib if (pm.globals.has('jsrsasign-js')) generateJwt();
else pm.sendRequest( 'https://kjur.github.io/jsrsasign/jsrsasign-all-min.js', function (err, res) { if (err) { console.log(err); } else { pm.globals.set('jsrsasign-js', res.text()); generateJwt(); }});
Enter fullscreen modeExit fullscreen mode

Then it creates the following request

curl --location curl --location 'https://xxxx.xxxx.darkedges.com/openam/oauth2/bc-authorize' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: ••••••' \
--data-urlencode 'request=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im15Q2liYUtleSJ9.eyJhdWQiOiJodHRwczovL3h4eHgueHh4eC5kYXJrZWRnZXMuY29tL29wZW5hbS9vYXV0aDIiLCJiaW5kaW5nX21lc3NhZ2UiOiJBbGxvdyBFeGFtcGxlQmFuayB0byB0cmFuc2ZlciDCozUwIGZyb20gJ01haW4nIHRvICdTYXZpbmdzJz8gKEVCLTAyNDYzMjYpIiwiYWNyX3ZhbHVlcyI6ImVtYWlsIiwiZXhwIjoxNzIxMDg1ODQ2LCJpc3MiOiJCYWNrQ2hhbm5lbCIsImxvZ2luX2hpbnQiOiI8ZW1haWwgYWRkcmVzcyBvZiB1c2VyPiIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUifQ.BA-FkcK4c6J8Heh7dxT6RJLmW6HTe4FZ7b2KSRcFiOFQhp18y_9Taquj0LRNcDBz8w_1qHejvfEAWCEtv8ZosQ'
Enter fullscreen modeExit fullscreen mode

Which returns

{ "auth_req_id": "TNrRNWXB86jFthHehaBfyinMpYI", "expires_in": 600, "interval": 2
}
Enter fullscreen modeExit fullscreen mode

Get Access Token

Now we can get the Access Token for the request.

curl --location 'https://xxxx.xxxx.darkedges.com/openam/oauth2/access_token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: ••••••' \
--data-urlencode 'auth_req_id=TNrRNWXB86jFthHehaBfyinMpYI' \
--data-urlencode 'grant_type=urn:openid:params:grant-type:ciba'
Enter fullscreen modeExit fullscreen mode

which returns

{ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIodXNyITxlbWFpbCBhZGRyZXNzIG9mIHVzZXI-KSIsImN0cyI6Ik9BVVRIMl9TVEFURUxFU1NfR1JBTlQiLCJhdWRpdFRyYWNraW5nSWQiOiJjMGZlNzcwMi02NTE1LTRhMjgtYjFkNi1kNjMxZTRhYjcxNjAtMTM5MjIiLCJzdWJuYW1lIjoiPGVtYWlsIGFkZHJlc3Mgb2YgdXNlcj4iLCJpc3MiOiJodHRwczovL3h4eHgueHh4eC5kYXJrZWRnZXMuY29tL29wZW5hbS9vYXV0aDIiLCJ0b2tlbk5hbWUiOiJhY2Nlc3NfdG9rZW4iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiYXV0aEdyYW50SWQiOiJzVWZHUE1ZMUpjV3J6NVVsWVhHTnhnTGs5c28iLCJhdWQiOiJCYWNrQ2hhbm5lbCIsIm5iZiI6MTcyMTA4NTYzOCwiZ3JhbnRfdHlwZSI6InVybjpvcGVuaWQ6cGFyYW1zOmdyYW50LXR5cGU6Y2liYSIsInNjb3BlIjpbIm9wZW5pZCIsInByb2ZpbGUiXSwiYXV0aF90aW1lIjoxNzIxMDg1NjM4LCJyZWFsbSI6Ii9jb25uZWN0aWQiLCJleHAiOjE3MjEwODkyMzgsImlhdCI6MTcyMTA4NTYzOCwiZXhwaXJlc19pbiI6MzYwMCwianRpIjoiQ1dPTXh5UEpxUzdFR2huNWxzemJjYWF1SlVrIn0.eVG8sBmJY6BvROzDTOjKfGTXzK8SdcYMWbLanrLCgUk", "scope": "openid profile", "id_token": "eyJ0eXAiOiJKV1QiLCJraWQiOiI4dEZoOURsSnI4SU56OTl6VjBCU094UkdieE09IiwiYWxnIjoiSFMyNTYifQ.eyJhdF9oYXNoIjoiMkJWeDJYZ2Y1UmZJdlhnaEpWVlVpQSIsInN1YiI6Iih1c3IhPGVtYWlsIGFkZHJlc3Mgb2YgdXNlcj4pIiwiYXVkaXRUcmFja2luZ0lkIjoiYzBmZTc3MDItNjUxNS00YTI4LWIxZDYtZDYzMWU0YWI3MTYwLTEzOTIzIiwic3VibmFtZSI6IjxlbWFpbCBhZGRyZXNzIG9mIHVzZXI-IiwiaXNzIjoiaHR0cHM6Ly94eHh4Lnh4eHguZGFya2VkZ2VzLmNvbS9vcGVuYW0vb2F1dCIsInRva2VuTmFtZSI6ImlkX3Rva2VuIiwiZ2l2ZW5fbmFtZSI6IkRlbW8iLCJhdWQiOiJCYWNrQ2hhbm5lbCIsImF6cCI6IkJhY2tDaGFubmVsIiwiYXV0aF90aW1lIjoxNzIxMDg1NjM4LCJuYW1lIjoiRGVtbyBVc2VyIiwicmVhbG0iOiIvY29ubmVjdGlkIiwiZXhwIjoxNzIxMDg5MjM4LCJ0b2tlblR5cGUiOiJKV1RUb2tlbiIsImlhdCI6MTcyMTA4NTYzOCwiZmFtaWx5X25hbWUiOiJVc2VyIn0.7YxVoJ7s-enmQrbNduOQ5Aq57LAi5XVeHApvlzAOJnU", "token_type": "Bearer", "expires_in": 3599
}
Enter fullscreen modeExit fullscreen mode

Introspect the Access Token

We can now finally introspect the Access Token

curl --location 'https://fram.connectid.darkedges.com/openam/oauth2/introspect' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: ••••••' \
--data-urlencode 'token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIodXNyITxlbWFpbCBhZGRyZXNzIG9mIHVzZXI-KSIsImN0cyI6Ik9BVVRIMl9TVEFURUxFU1NfR1JBTlQiLCJhdWRpdFRyYWNraW5nSWQiOiJjMGZlNzcwMi02NTE1LTRhMjgtYjFkNi1kNjMxZTRhYjcxNjAtMTM5MjIiLCJzdWJuYW1lIjoiPGVtYWlsIGFkZHJlc3Mgb2YgdXNlcj4iLCJpc3MiOiJodHRwczovL3h4eHgueHh4eC5kYXJrZWRnZXMuY29tL29wZW5hbS9vYXV0aDIiLCJ0b2tlbk5hbWUiOiJhY2Nlc3NfdG9rZW4iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiYXV0aEdyYW50SWQiOiJzVWZHUE1ZMUpjV3J6NVVsWVhHTnhnTGs5c28iLCJhdWQiOiJCYWNrQ2hhbm5lbCIsIm5iZiI6MTcyMTA4NTYzOCwiZ3JhbnRfdHlwZSI6InVybjpvcGVuaWQ6cGFyYW1zOmdyYW50LXR5cGU6Y2liYSIsInNjb3BlIjpbIm9wZW5pZCIsInByb2ZpbGUiXSwiYXV0aF90aW1lIjoxNzIxMDg1NjM4LCJyZWFsbSI6Ii9jb25uZWN0aWQiLCJleHAiOjE3MjEwODkyMzgsImlhdCI6MTcyMTA4NTYzOCwiZXhwaXJlc19pbiI6MzYwMCwianRpIjoiQ1dPTXh5UEpxUzdFR2huNWxzemJjYWF1SlVrIn0.eVG8sBmJY6BvROzDTOjKfGTXzK8SdcYMWbLanrLCgUk
Enter fullscreen modeExit fullscreen mode

returns

{ "active": true, "scope": "openid profile", "realm": "/connectid", "client_id": "BackChannel", "user_id": "<email address of user>", "username": "<email address of user>", "token_type": "Bearer", "exp": 1721089238, "sub": "([email protected])", "iss": "https://xxxx.xxxx.darkedges.com/openam/oauth2", "subname": "<email address of user>", "authGrantId": "sUfGPMY1JcWrz5UlYXGNxgLk9so", "auditTrackingId": "c0fe7702-6515-4a28-b1d6-d631e4ab7160-13922", "expires_in": 3356
}
Enter fullscreen modeExit fullscreen mode

Final Thoughts

For a Proof of Concept this works for us and should give you the basics for other projects or to demonstrate how it works.

You can download the Journey and OAuth2Client Amster from https://gist.github.com/darkedges/e42a70b4c5a76e817154caafb5b7eb92


Discover more from Coursity

Subscribe to get the latest posts sent to your email.

Leave a Comment

Your email address will not be published. Required fields are marked *

Discover more from Coursity

Subscribe now to keep reading and get access to the full archive.

Continue reading

Scroll to Top
Various script support.