IBM Verify

IBM Verify

Join this online user group to communicate across Security product users and IBM experts by sharing advice and best practices with peers and staying up to date regarding product enhancements.

 View Only

Enriching OIDC Tokens with Dynamic User Attributes in IBM Verify

By Ewan Chalmers posted 3 hours ago

  

JSON Web Tokens (JWT) are widely used for secure authentication and authorization in modern applications. While standard JWT claims provide basic identity information, applications often require additional attributes to make authorization decisions. This guide demonstrates how to enrich OIDC JWT tokens with dynamic user attributes using IBM Verify's advanced rule attributes feature. It will walk you through two solution versions which read custom attributes from an external API and include them in JWT tokens issued by IBM Verify.

Scenario overview

Whenever a user completes an OIDC application login, the OIDC provider issues signed JWT tokens containing claims which can be used to identify the authenticated user. For example, the sub claim is used to uniquely identify the user and the email claim can be used to provide the user's email address.

JWT payload:

{
    "sub": "1234567890",
    "name": "John Doe",
    "email: "john.doe@example.com",
    ... other standard claims
}

JWT tokens provide a compact and secure way to exchange that information. When a front-end application makes a request to a back-end service, it includes the JWT token in the Authorization header. The back-end service can then validate the token and extract the claims to make authorization decisions.

However, standard JWT claims may not be sufficient for all authorization needs. In a SaaS application, for example, a back-end service may need to know which customer (SaaS application tenant) the user belongs, that customer's purchased entitlements and the user's assigned roles to determine if the user is authorized to access the application or a particular resource. That additional information is not available directly to the OIDC provider, but from the SaaS application's back-end or a BSS connection.

These additional authorization attributes might be resolved on-demand by a back-end service, but that would require additional API calls, increasing request latency and the complexity of the back-end service.

If we can instead include these additional attributes in the JWT token, the back-end service can make authorization decisions without additional API calls. And as the JWT is signed by the OIDC provider, we can be confident that the claims have not been tampered with.

Extended JWT payload:

{
    "sub": "1234567890",
    "name": "John Doe",
    "email: "john.doe@example.com",
    "tenantid": "1234567890",
    "entitlements": ["premium", "analytics"],
    "roles": ["admin", "user"],
    ... other claims
}

Solution architecture

The solution architecture is a simple extension of the standard OIDC login flow. While handling the /authorize request, the Verify OIDC provider resolves custom attributes for the authenticating user. Advanced rules can be configured to call an external REST API and extract an attribute value from the response. The attribute values can then be added as custom claims when the JWT token is issued in the /token request.

IBM Verify Advanced Rule Attributes

IBM Verify attributes provide a means to include more user information to share with an application. Verify provides a number of types of attribute, which are defined in the Verify Directory.

For this scenario, we are using Advanced Rule attributes, which are defined as functions that execute during the authentication flow. We are specifically using HTTP Client functions, to call out to an external API.

Attribute functions are defined using a javascript-like syntax based on CEL (Common Expression Language). The attribute definition workflow includes an inline test capability, which allows you to test the function and see the results before saving the attribute - an invaluable aid.

Implementation

External API

For this scenario, I connected to a corporate customer entitlement management system to retrieve user information. The API endpoint was /profile/:userId, which returned a JSON response including user details, roles, and tenant information. The API required authentication via an API key in the request headers.

Sample response

[
  {
    "uid": "123456789",
    "email": "john.doe@example.com",
    "roles": [ "admin"],
    "tenant": {
        "customer_id": "123",
        "company_name": "Example Company",
        "tenant_pid": "123A",
        "tenant_id": "123X"
      }
  }
]

Creating Advanced Rule Attributes

The workflow to define and apply an Advanced Rule Attribute is as follows:

Define

  1. Login the IBM Verify admin console and navigate to the Directory / Attributes section
  2. Click Add attribute and select Advanced rule
  3. Select the Single sign-on purpose, enter name and description
  4. Define and test the function
  5. Save

Apply

  1. Navigate to the Applications section and select the application to which the attribute should be applied
  2. Navigate to the Attributes tab
  3. Select the Advanced rule attribute from the list
  4. Save

Sample function using API key authentication

Here is an example of an advanced attribute rule definition which invokes the external endpoint with API key authentication and extracts the tenant identifier from the response.

(Note: Substitute $API_HOST, $CLIENT_ID and $CLIENT_SECRET with hardcoded values for the environment.)

statements:

    # the id of the authenticated user
    - context: userId := user.id

    # configuration for the API request
    - context: apiHost := "${API_HOST}"
    - context: apiPath := "/profile/"
    - context: url := "https://" + context.apiHost + context.apiPath + context.userId

    # make API request to application backend, passing external API credentials
    - context: >
        data := hc.GetAsJson(context.url,
            {
                "content-type": "application/json", 
                "X-Client-Id": "${CLIENT_ID}",
                "X-Client-Secret": "${CLIENT_SECRET}"
            }
        )

    # parse the API response for return
    - return: context.data.tenants[0].id

Alternative: Using Verify private key JWT authentication

In the previous example, credentials for the external API must be hard-coded in the rule definition.

An alternative approach is to use Verify private key JWT authentication. In this case, an API client ID is hard-coded in the function definition and used to generate a private key JWT which is passed to the external API.

Using this method we might choose to wrap the external API via the application's back-end service, which would then be responsible for validating the JWT and passing the request to the external API. This approach would allow the external API credentials to be stored in the back-end service and not exposed in the rule definition.

This approach requires an additional setup step in Verify.

Create an API client for generation of private key JWT:

  1. Login the IBM Verify admin console
  2. Navigate to the Applications section and select the OIDC application
  3. Select the API access tab and click Add API client
  4. Client authentication method: select Private key JWT
  5. JWKS URI: enter the JWKS URI of the OIDC application
  6. Access token format: select JWT
  7. Save
  8. Copy the Client ID for the API client

We can now use this Client ID in a rule definition to create a private key JWT when the function is called and use the to authenticate the request to the application's back-end service.

As the JWT is signed with the same key as the OIDC login, the application's back-end service can use the same authentication check and JWT validation as for requests from the front-end application.

Sample function using Verify private key JWT authentication

statements:

    # the id of the authenticated user
    - context: userId := user.id

    # configuration for private JWT
    - context: clientId := "${JWT_CLIENT_ID}"
    - context: tenantId := "${VERIFY_TENANT}"
    - context: issuer := "https://" + context.tenantId + "/oidc/endpoint/default"

    # configuration for application back-end request
    - context: appHost := "${APP_HOST}"
    - context: appPath := "/api/entitlements/"
    - context: url := "https://" + context.appHost + context.appPath + context.userId

    # prepare private JWT payload
    - context: >
        clientAuthJWT := jwt.sign({
            "iss": context.clientId,
            "sub": context.clientId,
            "aud": "https://" + context.tenantId + "/oauth2/token",
            "jti": genUUID(),
            "exp": int(now + duration("600s"))
        }, {})

    # request private JWT
    - context: >
        roauth := hc.Post("https://" + context.tenantId + "/oauth2/token", {"accept": "application/json", "content-type": "application/x-www-form-urlencoded"}, jsonToFormURLEncoded({
            "client_id": context.clientId,
            "client_assertion": context.clientAuthJWT,
            "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
            "grant_type": "client_credentials"
        }, true))

    # validate private JWT
    - if:
        match: >
            !has(context.roauth.responseBody) || !has(context.roauth.responseBody.access_token)
        block:
            - debugx:
                log: >
                    "Failed to get OAuth token"
                fields:
                    roauth: jsonToString(context.roauth)
            - context: >
                    error := has(context.roauth.responseBody) && has(context.roauth.responseBody.error) 
                                        ? context.roauth.responseBody.error 
                                        : ""
            - context: >
                    errorDetails := has(context.roauth.responseBody) && has(context.roauth.responseBody.error_description) 
                                        ? context.roauth.responseBody.error_description 
                                        : "Unable to get a valid token."
            - return: >
                    {
                        "result": "error: " + context.error,
                        "errorDetails": context.errorDetails,
                        "errorMessage": "Unable to get a valid token."
                    }
    
    # save private JWT
    - context: apiToken := context.roauth.responseBody.access_token


    # make backend request, using private JWT
    - context: >
        data := hc.GetAsJson(context.url,
            {
                "content-type": "application/json", 
                "authorization": "Bearer " + context.apiToken
            }
        )

    # parse the API response for return
    - return: context.data.tenants[0].id

Authentication method vs request caching (gotcha)

In the real-world implementation that this guide is derived from, three custom attributes were extracted from the same external API response payload: tenant_id, entitlements and roles.

However, Verify advanced rule attribute function can return only a single value - so three separate advanced rule attribute functions were created, each making its own call to the same external API.

As noted in the HTTP Client functions documentation, Verify provides caching of HTTP Client request/responses. So three separate calls to the same API endpoint should result in only one API call to the external API.

The 'gotcha' is that (quite reasonably) the caching is keyed on the URL and headers of the request. When using the private key JWT authentication method described above, the three requests have different authentication headers so the caching is not used, resulting in three calls to the external API for one authentication flow.

A simple solution is to use the API key authentication method also described above. The three functions can use the same API key, produce the same authentication headers and so the caching is used, resulting in only one call to the external API.

0 comments
9 views

Permalink