Implementing OIDC back-channel logout on ISAM/ISVA


Author(s): László Czap | Created: 16 December 2021 | Last modified: 16 December 2021
Tested on: -

OIDC back-channel logout

The back-channel logout flow is an extension to the OIDC specification (here: https://openid.net/specs/openid-connect-backchannel-1_0.html). This extension adds the single-logout functionality to the protocol suite. The protocol flow consists of sending a JWT format logout token from the OP to all affected RPs, which is a back-channel communication between two providers. This feature is not natively implemented in ISAM/ISVA, but we can add it using the tools we have in the system.

This write-up is about implementing the OP side flow, i.e. sending the logout tokens, not the processing of a received logout token.

At a high level we need the following main components:

  1. Register the logout URI for the clients
  2. Upon token request, mark the client as a logout target
  3. Create an STS chain that produces the logout JWT token
  4. Create an endpoint that triggers the logout flow (sending the logout token to clients that use a token)
  5. (Optional) Apply an HTTP transformation to make the URL look nicer
  6. Extend OP metadata indicating that back-channel logout is supported
  7. (Optional) Register the logout trigger endpoint as a logout URI on the reverse proxy to make it automatic on session termination

Registering the logout URI for the clients

On the client configuration form we have no option to set the logout URI, but there is the option to add 'Extension Properties', which is perfectly suitable for the purpose.

We need two attributes:

  1. backchannel_logout_uri: where the logout token is consumed
  2. backchannel_logout_session_required: is an indication whether a session identifier is needed in the logout token. This is optional, if not there we'll assume false.

We choose to use JSON format to make our life easier. Our 'Extension Properties' for our test client look like:

{
  "backchannel_logout_uri": "https://192.168.11.216/testclientLogout",
  "backchannel_logout_session_required": true
}

Upon token request, mark the client as a logout target

We choose to mark the client for logout when the call to the authorize endpoint is done. If there is a registered logout URI for the client, we associate the clientid with the authorization grant such that later we can retrieve it. We also choose to index the saved ids by the session index of the authenticated user. By this, we can selectively terminate RP sessions that are created in the given OP session.

Note 1: Using the authorize endpoint works for the most common authorization code grant. For other grant types a slight modification might be needed.

Note 2: The use of the authorize endpoint does not always imply issuing a login token. It is possible to do the same upon use of the token endpoint. The only difference is that the user session is not available on that call. To be able to selectively terminate sessions, one would need to save the session index attribute on the authorize request paired with the authorization code and then retrieve it on the token enpoint based on the authorization code and save the clientid together with the session index.

The following code snippet is added to the post-token generation mapping rule to do the job:

var is_oidc_scope = false;

    temp_attr = stsuu.getContextAttributes().getAttributeByName("scope");

    if (temp_attr != null) {
        for( var scope in temp_attr.getValues()) {
            if (temp_attr.getValues()[scope] == "openid") {
                is_oidc_scope = true;
                break;
            }
        }
    }

if (request_type == 'authorization' && is_oidc_scope){
        client_ext_data =  JSON.parse(oauth_client.getExtendedData());
        client_logout_uri = client_ext_data["backchannel_logout_uri"];
        //here we check if a logout url is registered. If so, we save the client id to be able to call back on logout.
        IDMappingExtUtils.traceString("Cheking for logout uris...");
        if (client_logout_uri != null) {
            IDMappingExtUtils.traceString("Saving client ID for logout: "+oauth_client.getClientId());
            save_clientid_for_logout(oauth_client.getClientId());       
        }
    }

function save_clientid_for_logout(client_id){

    var session_ix = ""+stsuu.getAttributeContainer().getAttributeValueByNameAndType("tagvalue_session_index","urn:ibm:names:ITFIM:5.1:accessmanager");

    var logoutClients = ""+OAuthMappingExtUtils.getAssociation(state_id,"logoutClients");
    IDMappingExtUtils.traceString("logoutclient: "+logoutClients);
    if (logoutClients =! "null"){
        logoutClients = JSON.parse(logoutClients);
        if (logoutClients[session_ix] == null) {
            logoutClients[session_ix] = [];
        }
    }else {
        logoutClients = JSON.parse('{"'+session_ix+'":[]}');
    }


    logoutClients[session_ix].push(""+client_id);

    IDMappingExtUtils.traceString("logoutclient: "+JSON.stringify(logoutClients));

    OAuthMappingExtUtils.associate(state_id, "urn:ibm:am:logoutclients", JSON.stringify(logoutClients));
}

Create an STS chain that produces the logout JWT token

We will use this chain from the script STS client. The simplest option is to use the STSUU format as input and simply issue a JWT. We do not need a mapping, we will populate the STSUU object with the required content before calling the chain. The chain template has two modules:

  1. Default STSUU, Mode: Validate
  2. Default Jwt Module, Mode: Issue

We name our template as OIDC Logout Token.

Next, we create a chain instance:

  • Name: OIDC_Logout
  • Template: OIDC Logout Token
  • Request Type: Issue
  • Lookup Type: Traditional WS-Trust Elements
  • Applies to Address: oidclogout
  • Issuer Address: oidclogout
  • Token Type: Any
  • JWT Signing / Signature algorithm: RS256
  • JWT Signing / Certificate Database: rt_profile_keys
  • JWT Signing / Certificate Label: server

Note 1: Singing the token is mandatory by specification. All the other JWT attributes will be set from script.

Note 2: In a production system use a dedicated key for token signing.

Create an endpoint that triggers the logout flow

For this purpose we are going to use a custom InfoMap authentication module. Of course, this will not perform any kind of authentication, but we get script control with access to user session attributes.

Note: The specification does not tell anything about how to trigger the logout. We find it natural to publish an HTTP GET endpoint to initiate the flow.

Create a template page for the InfoMap

We need a page to be displayed when the trigger URL is called. We simply created an empty html file in the template pages folder C/OIDC/OIDCBackLogout.html.

Create a mapping rule for InfoMap

The rule logic of the InfoMap connects everything together and executes the actual login flow. It reads the saved logout client ids for the session from the authorization grants of the user, retrieves the registered logout URIs from the client definitions and constructs a logout JWT token for each using the STS chain. It then posts the token using the script HTTP client to each relevant RP.

We created the file named OIDCBackLogout of type InfoMap. Here is the content:

importPackage(Packages.com.tivoli.am.fim.trustserver.sts);
importPackage(Packages.com.tivoli.am.fim.trustserver.sts.oauth20);
importPackage(Packages.com.tivoli.am.fim.trustserver.sts.uuser);
importPackage(Packages.com.ibm.security.access.user);
importClass(Packages.com.tivoli.am.fim.trustserver.sts.utilities.IDMappingExtUtils);
importClass(Packages.com.tivoli.am.fim.trustserver.sts.utilities.OAuthMappingExtUtils);
importClass(Packages.com.ibm.security.access.httpclient.HttpClient);
importClass(Packages.com.ibm.security.access.httpclient.HttpResponse);
importClass(Packages.com.ibm.security.access.httpclient.Headers);
importClass(Packages.com.ibm.security.access.httpclient.Parameters);
importClass(Packages.com.tivoli.am.fim.fedmgr2.trust.util.LocalSTSClient);
importClass(Packages.java.util.ArrayList);
importClass(Packages.java.util.HashMap);


var username = context.get(Scope.SESSION, "urn:ibm:security:asf:response:token:attributes", "username");
var session_ix = context.get(Scope.SESSION, "urn:ibm:security:asf:response:token:attributes", "tagvalue_session_index");

IDMappingExtUtils.traceString("Logout user:" + username);
IDMappingExtUtils.traceString("Session ix user:" + session_ix);

var grants = OAuthMappingExtUtils.getGrants(username);
var logoutClientList = [];
for (i=0;i<grants.length;i++){
    var stateId = grants[i].getStateId();
    logoutclients = JSON.parse(OAuthMappingExtUtils.getAssociation(stateId,"urn:ibm:am:logoutclients"));

    if (logoutclients != null){

        var sessionClients = logoutclients[session_ix];
        if (sessionClients != null){
            for (j = 0; j < sessionClients.length; j++) {
                logoutClientList.push(sessionClients[j]);
            } 
        }
    }
}

for (i=0; i< logoutClientList.length; i++){
    var clientId = logoutClientList[i];
    var client = OAuthMappingExtUtils.getClient(clientId);
    var ext_data = JSON.parse(client.getExtendedData());
    var logoutURI = ext_data.dynamic_client["backchannel_logout_uri"];
    var includeSID = ext_data.dynamic_client["backchannel_logout_session_required"] == "true" ? true:false;
    var sid = includeSID ? session_ix : null;

    IDMappingExtUtils.traceString("CLIENT ID: "  + clientId);
    IDMappingExtUtils.traceString("LOGOUT URI: "  + logoutURI);

    do_logout(username,clientId,logoutURI,sid)
}


function do_logout(username,clientId,uri,sid){
    var stsuu = new STSUniversalUser();

    var client = OAuthMappingExtUtils.getClient(clientId);
    var definitionId = client.getDefinitionID();
    var definition = OAuthMappingExtUtils.getDefinitionByID(definitionId);
    var oidc = definition.getOidc();
    var issuer = ""+oidc.getIss();

    add_claim(stsuu,"iss",issuer);

    //We choose to always add 'sub'. It is not mandatory when using 'sid'...
    add_claim(stsuu,"sub",username);

    add_claim(stsuu,"aud",clientId);

    var now = new Date();
    var now_epoch = now.getTime();
    now_epoch_sec = Math.floor(now_epoch/1000);
    var iat = String(now_epoch_sec);    
    add_claim(stsuu,"iat",iat);

    var jti = OAuthMappingExtUtils.generateRandomString(4);
    add_claim(stsuu,"jti",jti);

    add_claim(stsuu,"events",'{"http://schemas.openid.net/event/backchannel-logout": {} }');

    if (sid != null){
        add_claim(stsuu,"sid",sid);
    }   

    var stsRes = LocalSTSClient.doRequest("http://schemas.xmlsoap.org/ws/2005/02/trust/Issue", "oidclogout", "oidclogout", stsuu.toXML().getDocumentElement(), null);

    if (stsRes.token) {
        logoutJWT = stsRes.token.getTextContent();
        IDMappingExtUtils.traceString("LOGOUT JWT: "  + logoutJWT);
        call_logout_endpoint(uri,logoutJWT);
    } else {
        IDMappingExtUtils.traceString("Error getting token from STS: " + stsRes.errorMessage);
        //We choose not to throw an exception to give a chance for the rest of logout tokens to be sent.
    }   
}

function add_claim(stsuu,name,value){
    var attr = new com.tivoli.am.fim.trustserver.sts.uuser.Attribute(name, "urn:ibm:jwt:claim", value);
    stsuu.addAttribute(attr);
}

function call_logout_endpoint(uri,jwt){
    headers = new Packages.com.ibm.security.access.httpclient.Headers();
    IDMappingExtUtils.traceString("LOGOUT URI: "  + uri);
    headers.addHeader("Content-Type","application/x-www-form-urlencoded");
    parameters = new Packages.com.ibm.security.access.httpclient.Parameters();
    parameters.addParameter("logout_token",jwt);

    http_reponse = com.ibm.security.access.httpclient.HttpClient.httpPost(uri, headers, parameters,"httpClient",null,null,null,null);
}

page.setValue("/OIDC/OIDCBackLogout.html");

success.endPolicyWithoutCredential();

Create an authentication mechanism

We need to create a new InfoMap authentication mechanism using the mapping rule above. We use the attributes as follows. Note that we don't need a template page.

Create an authentication policy

To be able to trigger the mechanism, we need an authentication policy. Our new authentication policy simply looks like:

Having this, we have a logout URL (our AAC junction is /demo): /demo/sps/apiauthsvc?PolicyId=urn:ibm:security:authentication:asf:oidc:bclogout

(Optional) Apply an HTTP transformation to make the URL look nicer

The logout URL we created is not very friendly. There is no standard endpoint, but we want that it is good looking.

With omitting step-by-step detalis on how to create an HTTP transformation, we create a transformation that simply rewrites the URL (boilerplate parts omitted):

<xsl:template match="//HTTPRequest/RequestLine/URI">    <URI>/demo/sps/apiauthsvc?PolicyId=urn:ibm:security:authentication:asf:oidc:bclogout</URI>
    </xsl:template>

We then apply this transformation on URL /demo/testOIDC/logout which will serve as our logout trigger URL.

Extend OP metadata indicating that back-channel logout is supported

Edit the template file C/oauth20/metadata.json and add the following 3 lines:

"backchannel_logout_trigger_uri": "@POC_PREFIX@@OAUTH_DEFINITION@/logout",
"backchannel_logout_supported":<%var supported = true;templateContext.response.body.write(supported);%>,
"backchannel_logout_session_supported":<%var supported = true;templateContext.response.body.write(supported);%>

Note 1: The backchannel_logout_trigger_uri is not part of the specification, but we find it useful.

Note 2: In our case @POC_PREFIX@@OAUTH_DEFINITION@ is equal to /demo/testOIDC. The value you set for backchannel_logout_trigger_uri must match the logout trigger URL as defined by the HTTP transformation or the authentication policy address.

(Optional) Register the logout trigger endpoint as a logout URI on the reverse proxy to make it automatic on session termination

In your reverse proxy configuration you can use the single-signoff-uri attribute in the [acnt-mgt] stanza to automate triggering the single logout when terminating the session. In certain cases this comes handy, in other cases it causes headache. This is not part of any OIDC specification.

[acnt-mgt]
single-signoff-uri = /demo/sps/apiauthsvc?PolicyId=urn:ibm:security:authentication:asf:oidc:bclogout

The configuration described here is tested on ISVA 10.0.0.