Cordra supports integration with third-party authentication providers. This is done by implementing the server-side
authenticate
lifecycle hook and providing custom client-side HTML and JavaScript.
This section will show a complete example, UI and server side, for authenticating with a third party using OpenID Connect.
This requires 3 separate components:
An HTML file that will be loaded client-side into an iframe as part of the login dialog.
An HTML file used to accept the callback parameters from the OpenId Connect server.
A JavaScript implementation of the authenticate
hook. This processes the authentication information sent back from the OpenId Connect server.
Cordra supports a UI hook for inserting a custom HTML file into a tab, inside an iframe, as part of the login dialog.
That part of the UI can be configured by signing in as admin and then, in the Cordra UI, selecting Admin->UI.
Set the following as a top level JSON property:
"customAuthentication": {
"url": "https://localhost:8443/objects/design?payload=login-iframe",
"tabName": "OpenId Connect",
"height": 100
}
The url
points at the custom HTML page that is stored as a payload on the design object.
The iframe will be created in a new tab in the authentication dialog. You can customize the name
of that tab with the tabName
property.
The height
property allows you to control the height of the iframe in the new tab.
The HTML file is fully controlled by the developer and communicates with the parent window (the main Cordra UI) using the postMessage
API in two ways:
It sends an authentication token received from a third-party system to the parent window.
It listens for postMessage
events from the parent window to detect when a user has signed out.
Below is a complete HTML file you can use with OpenId Connect.
Save this as a file named login-iframe.html
. Then store the file as an element on the design object.
Name the element “iframe-login”.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenId Connect Login</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.login-button {
background: #0066cc;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
}
.login-button:hover { background: #0052a3; }
</style>
</head>
<body>
<button id="loginButton" class="login-button" style="display:block"></button>
<p id="uiMessage" style="display:none">Login successful</p>
<script>
// Login.gov config
const config = {
loginButtonText: "Sign In With Login.gov",
client_id: "<client_id_from_login.gov>",
redirect_uri: "https://localhost:8443/objects/design?payload=callback",
post_logout_redirect_uri: "https://localhost:8443/objects/design?payload=callback",
authorization_endpoint: "https://idp.int.identitysandbox.gov/openid_connect/authorize",
end_session_endpoint: "https://idp.int.identitysandbox.gov/openid_connect/logout",
acr_values: "urn:acr.login.gov:auth-only"
};
const loginButton = document.getElementById('loginButton');
const uiMessage = document.getElementById('uiMessage');
loginButton.textContent = config.loginButtonText;
let nonce = null;
let state = null;
loginButton.addEventListener('click', (event) => {
login();
});
window.addEventListener("message", (event) => {
let message = event.data;
if (message) {
console.log("iframe got a message");
console.log(JSON.stringify(message));
if (message.type === "signOut") {
uiMessage.style.display = 'none';
signOut();
} else if (message.type === "callbackResponse") {
if (message.error) {
if (message.error === 'access_denied') {
uiMessage.textContent = "Login was cancelled. Please try again.";
uiMessage.style.display = 'block';
} else {
console.log("Login failed. Please try again." );
uiMessage.textContent = "Login failed. Please try again.";
uiMessage.style.display = 'block';
}
} else if (message.code) {
if (message.state !== state) {
console.log("State did not match. Please try again." );
uiMessage.textContent = "Login failed. Please try again.";
uiMessage.style.display = 'block';
return;
}
// post a message to the main UI
uiMessage.textContent = "Login successful.";
uiMessage.style.display = 'block';
const token = generateTokenFrom(message.code, nonce);
const mainUiMessage = {
type: "customAuthentication",
token: token
};
window.parent.postMessage(mainUiMessage, '*');
}
}
}
});
function generateTokenFrom(code, nonce) {
let tokenObj = { code, nonce };
let tokenJson = JSON.stringify(tokenObj);
let result = btoa(tokenJson);
return result;
}
function login() {
nonce = generateSecureString(32);
state = generateSecureString(32);
let loginUrl = config.authorization_endpoint;
loginUrl += "?client_id=" + config.client_id;
loginUrl += "&nonce=" + nonce;
loginUrl += "&prompt=select_account";
loginUrl += "&redirect_uri=" + encodeURIComponent(config.redirect_uri);
loginUrl += "&response_type=code";
loginUrl += "&scope=openid+email";
loginUrl += "&state=" + state;
if (config.acr_values) {
loginUrl += "&acr_values=" + config.acr_values;
}
console.log(loginUrl);
window.open(loginUrl, 'login', 'width=500,height=700,scrollbars=yes,resizable=yes');
}
function signOut() {
if (config.end_session_endpoint) {
let signOutUrl = config.end_session_endpoint;
signOutUrl += "?client_id=" + config.client_id;
signOutUrl += "&post_logout_redirect_uri=" + encodeURIComponent(config.post_logout_redirect_uri);
window.open(signOutUrl, 'signout', 'width=500,height=700,scrollbars=yes,resizable=yes');
}
}
function generateSecureString(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length];
}
}
return result;
}
</script>
</body>
</html>
Here the custom login page constructs a url to the OpenId Connect authorization_endpoint
and then
opens that endpoint in a popup window. That popup window will allow the user to enter their credentials
with the third party.
The third party will then redirect the popup window to a preconfigured redirect_uri
with the authentication result.
That is another HTML file that you need to provide. Here is a complete example of the callback HTML file that
can be used by the redirect_uri
. Its job is to extract the response from the third party authentication service
and then use postMessage
to forward that response on to your HTML file that loaded the popup.
Save this as a file named callback.html
. Then store the file as an element on the design object.
Name the element “callback”.
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script>
const urlParams = new URLSearchParams(window.location.search);
const code = getParam(urlParams, "code");
const state = getParam(urlParams, "state");
const error = getParam(urlParams, "error");
const errorDescription = urlParams.get('error_description');
let message = {
type: "callbackResponse",
code, state, error, errorDescription
};
window.opener.postMessage(message, '*')
setTimeout(() => {
window.close();
}, 500);
function getParam(urlParams, name) {
let param = urlParams.get(name);
if (!param) {
param = urlParams.get("?" + name);
}
return param;
}
</script>
</body>
</html>
When the UI code receives the result from the third party, it uses postMessage to send it to the main Cordra UI page. That page then authenticates to Cordra in the usual way providing the custom authentication.
You need to provide custom server-side authentication code to handle that custom authentication. In the case of OpenId Connect used in this example, the server-side code does four things.
Contacts the third party to exchange the temporary code it already received with a JSON Web Token (JWT).
Retrieves the public key of the third party and uses it to verify the JWT.
Compares the nonce in the JWT with the nonce it got from the client.
Extracts the email of the user from the JWT and uses that as the userId.
Below is a complete example of the server-side JavaScript.
In the Cordra UI, select Admin->Design JavaScript and replace it with the following.
const cordraUtil = require('cordraUtil');
exports.authenticate = authenticate;
//LOGIN.GOV config
const config = {
client_id: "<client_id_from_login.gov>",
token_endpoint: "https://idp.int.identitysandbox.gov/api/openid_connect/token",
redirect_uri: "https://localhost:8443/objects/design?payload=callback",
jwks_uri: "https://idp.int.identitysandbox.gov/api/openid_connect/certs",
sendClientAssertion: true,
pathToPrivateKey: "/path/to/private.jwk",
};
let privateKeyJwk = null;
let verifyKeys = null;
function authenticate(authInfo, context) {
console.log(JSON.stringify(authInfo));
const now = Date.now()
if (authInfo.username === "admin") {
return null;
}
if (authInfo.authTokenInput) {
const clientToken = authInfo.authTokenInput.token;
const clientTokenObj = decodeClientToken(clientToken);
const token = exchangeCodeForToken(clientTokenObj.code);
if (token) {
const isVerified = verifyToken(token);
if (!isVerified) {
return {
active: false
};
}
const claimsObject = cordraUtil.extractJwtPayload(token);
console.log(JSON.stringify(claimsObject));
if (clientTokenObj.nonce) {
if (clientTokenObj.nonce !== claimsObject.nonce) {
console.log("Nonce from token exchenage does not match");
return {
active: false
};
}
}
const email = claimsObject.email;
const result = {
active: true,
userId: email,
username: email,
grantAuthenticatedAccess: true,
exp: getTimestampPlus24Hours(now)
};
return result;
}
}
return null;
}
function decodeClientToken(clientToken) {
const tokenJson = base64Decode(clientToken);
console.log(tokenJson);
const tokenObj = JSON.parse(tokenJson);
return tokenObj;
}
function exchangeCodeForToken(code) {
let params = {
grant_type: "authorization_code",
code: code,
redirect_uri: config.redirect_uri,
client_id: config.client_id
};
if (config.sendClientAssertion) {
const jws = createClientAssertion();
params["client_assertion"] = jws;
params["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
}
const response = post(config.token_endpoint, config.client_id, config.client_secret, params);
if (response.status === 200) {
let responseJson = JSON.parse(response.text);
return responseJson.id_token;
} else {
console.log("ERROR: Token exchange failed");
try {
const errorJson = JSON.parse(response.text);
console.log("Error details:", JSON.stringify(errorJson));
} catch (e) {
console.log("Could not parse error response as JSON");
}
return null;
}
}
function verifyToken(token) {
const keys = getVerifyKeys();
if (keys) {
for (const key of keys) {
const isVerified = cordraUtil.verifyWithKey(token, key);
if (isVerified) {
return true;
}
}
}
return false;
}
function getVerifyKeys() {
if (config.jwks_uri) {
if (verifyKeys) {
return verifyKeys;
} else {
const response = httpGet(config.jwks_uri);
if (response.status === 200) {
const responseJson = JSON.parse(response.text);
verifyKeys = responseJson.keys;
return verifyKeys;
} else {
return null;
}
}
}
return null;
}
function createClientAssertion() {
const jwk = getPrivateKey();
const now = Date.now();
const jti = generateJti();
const iat = msToSeconds(now);
const exp = getTimestampPlus24Hours(now);
const payload = {
iss: config.client_id,
sub: config.client_id,
aud: config.token_endpoint,
jti: jti,
exp: exp,
iat: iat
};
const jws = cordraUtil.signWithKey(payload, jwk);
return jws;
}
function generateJti() {
const timestamp = Date.now().toString(36);
const randomPart = Math.random().toString(36).substring(2, 15);
return timestamp + "-" + randomPart;
}
function getTimestampPlus24Hours(now) {
const twentyFourHours = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const t = now + twentyFourHours;
return msToSeconds(t);
}
function msToSeconds(t) {
return Math.floor(t / 1000);
}
function base64Encode(jsStr) {
const klass = java.lang.String.class;
const method = klass.getMethod("getBytes", klass);
const javaByteArray = method.invoke(jsStr, "UTF-8");
const encoded = Base64.getEncoder().encodeToString(javaByteArray);
return encoded;
}
function base64Decode(base64Str) {
const decodedBytes = Base64.getDecoder().decode(base64Str);
const stringKlass = java.lang.String.class;
const constructor = stringKlass.getConstructor(
java.lang.Class.forName("[B"),
java.lang.String.class
);
const javaString = constructor.newInstance(decodedBytes, "UTF-8");
return javaString.toString();
}
const HttpClients = Java.type("org.apache.http.impl.client.HttpClients");
const client = HttpClients.createDefault();
const HttpPost = Java.type("org.apache.http.client.methods.HttpPost");
const HttpGet = Java.type("org.apache.http.client.methods.HttpGet");
const Base64 = Java.type("java.util.Base64");
const StringEntity = Java.type("org.apache.http.entity.StringEntity");
const EntityUtils = Java.type("org.apache.http.util.EntityUtils");
const URLEncoder = Java.type("java.net.URLEncoder");
const Charset = Java.type("java.nio.charset.StandardCharsets");
function post(url, username, password, params) {
const post = new HttpPost(url);
if (username && password) {
const credentials = username + ":" + password;
const encodedCredentials = base64Encode(credentials);
post.setHeader("Authorization", "Basic " + encodedCredentials);
}
post.setHeader("Content-Type", "application/x-www-form-urlencoded");
const body = encodeParams(params);
const entity = new StringEntity(body, Charset.UTF_8);
post.setEntity(entity);
const response = client.execute(post);
const entityResp = response.getEntity();
return {
status: response.getStatusLine().getStatusCode(),
text: EntityUtils.toString(entityResp)
};
}
function httpGet(url) {
const getRequest = new HttpGet(url);
const response = client.execute(getRequest);
const entityResp = response.getEntity();
const text = EntityUtils.toString(entityResp);
console.log("RESPONSE:"+text);
return {
status: response.getStatusLine().getStatusCode(),
text: text
};
}
function encodeParams(params) {
const pairs = [];
for (let key in params) {
if (params[key] != null) {
const encodedKey = URLEncoder.encode(key, "UTF-8");
const encodedValue = URLEncoder.encode(params[key].toString(), "UTF-8");
pairs.push(encodedKey + "=" + encodedValue);
}
}
return pairs.join("&");
}
function getPrivateKey() {
if (!privateKeyJwk) {
privateKeyJwk = readFileToJsonAndParse(config.pathToPrivateKey);
}
return privateKeyJwk;
}
function readFileToString(pathToFile) {
const path = Java.type('java.nio.file.Paths').get(pathToFile);
const string = Java.type('java.nio.file.Files').readString(path);
return string;
}
function readFileToJsonAndParse(pathToFile) {
const jsonString = readFileToString(pathToFile);
const result = JSON.parse(jsonString);
return result;
}
Once you’ve finished the configuration, log out and refresh the page. When you click the login button again, you should now see a option to log in with OpenID Connect. Choose that option, and log in using the flow defined by your provider. Once successful, you should be logged into Cordra with the appropriate external username.
The above example code showed configuration for login.gov. Configuration is done in code in both the iframe-login.html file and in the server-side design JavaScript.
OpenId Connect providers typically expose a .well-known
url that you can resolve to get some of these configuration
parameters. In particular authorization_endpoint
, jwks_uri
, and if used end_session_endpoint
can be
obtained from the .well-known endpoint, e.g. https://accounts.google.com/.well-known/openid-configuration.
You will have to interact with the third party server out of band to establish a client_id
. Your third-party provider
may also required you to set up a cryptographic key pair or a client_secret
.
You typically need to also configure your account on the third-party server with the redirect_uri
.
Below are example configurations that have been tested with login.gov, Stalwart, and Google.
Config for iframe-login.html:
const config = {
loginButtonText: "Sign In With Login.gov",
client_id: "<client_id_from_login.gov>",
redirect_uri: "https://localhost:8443/objects/design?payload=callback",
post_logout_redirect_uri: "https://localhost:8443/objects/design?payload=callback",
authorization_endpoint: "https://idp.int.identitysandbox.gov/openid_connect/authorize",
end_session_endpoint: "https://idp.int.identitysandbox.gov/openid_connect/logout",
acr_values: "urn:acr.login.gov:auth-only"
};
Login.gov requires you to create a cryptographic key pair. Once created the the public key needs to be uploaded to their
server. The private key is used to sign the server side request made to the token_endpoint
. That requires setting
the two config properties in Cordra sendClientAssertion
and pathToPrivateKey
.
You must first convert the private key from PEM format to JWK format. The Cordra distribution comes with a key conversion tool that can perform this conversion. e.g.
/path/to/cordra/distribution/bin/hdl-convert-key -f jwk private.pem -o private.jwk
Config for design JavaScript:
const config = {
client_id: "<client_id_from_login.gov>",
token_endpoint: "https://idp.int.identitysandbox.gov/api/openid_connect/token",
redirect_uri: "https://localhost:8443/objects/design?payload=callback",
jwks_uri: "https://idp.int.identitysandbox.gov/api/openid_connect/certs",
sendClientAssertion: true,
pathToPrivateKey: "/path/to/private-key/private.jwk",
};
To create an OAuth client in Stalwart, see here or contact your mail server administrator.
Config for iframe-login.html:
const config = {
loginButtonText: "Sign In With Stalwart",
client_id: "<stalwart_client_id>",
redirect_uri: "https://localhost:8443/objects/design?payload=callback&",
post_logout_redirect_uri: "https://localhost:8443/objects/design?payload=callback&",
authorization_endpoint: "https://mail.example.net/authorize/code",
end_session_endpoint: null,
acr_values: null
};
Config for design JavaScript:
const config = {
client_id: "<stalwart_client_id>",
token_endpoint: "https://mail.example.net/auth/token",
redirect_uri: "https://localhost:8443/objects/design?payload=callback&",
jwks_uri: "https://mail.example.net/auth/jwks.json",
sendClientAssertion: false
};
In order to connect to Google using OAuth, you’ll need to create an OAuth client here.
Config for iframe-login.html:
const config = {
client_id: "<client_id_from_google>",
client_secret: "<enter-client-secret-here>",
token_endpoint: "https://oauth2.googleapis.com/token",
redirect_uri: "https://localhost:8443/objects/design?payload=callback",
jwks_uri: "https://www.googleapis.com/oauth2/v3/certs",
sendClientAssertion: false
};
Config for design JavaScript:
const config = {
loginButtonText: "Sign In With Google",
client_id: "<client_id_from_google>",
redirect_uri: "https://localhost:8443/objects/design?payload=callback",
authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth"
};