Client-Initiated Backchannel Authentication (CIBA) in ASP.NET Core 10 with Duende Identity Server

AL Rodriguez |

When you sign in to a website, you enter your username/password, and maybe a two-factor auth code. You’re using your phone to authenticate yourself to a site, all on that same device. What if in the future, we used that same technology to authenticate ourselves to some other device, like a public kiosk, or even authenticating ourselves to a person we’re speaking to over the phone. That future is now. And it comes from Client-Initiated Backchannel Authentication (CIBA).

CIBA is an OpenId Foundation standard that extends OpenID Connect, enabling user authentication on a different device from the one running the application. CIBA builds on the OpenID Connect standard we all know and love, but separates the notion of the Consumption Device (where the user needs to be logged in) from the Authentication Device (where the user will perform authentication).

And before you ask, YES! It is fully supported by Duende IdentityServer at the Enterprise license tier.

A High-Trust Financial Scenario

Imagine you and 10 friends just had a very successful weekend at a Las Vegas casino. To keep things simple, all of the money went into one bank account, yours. After waiting a short while for absolutely no reason, it’s time to split it amongst everyone by sending wire transfers. Otherwise your newest friend, Mr. Ocean, will be very upset.

For large transfers, the bank requires you to call, verify the amount, and authorize the transfer with a teller.

From the bank’s perspective, any random person can call them with the right information and transfer money out of someone’s account. How does the bank authenticate the customer remotely? This trust gap is where CIBA comes in.

Once the customer has verified some essential information, the teller initiates the CIBA request. A notification is sent to the customer’s phone, where they sign in to the app using private credentials and multi-factor authentication, then approve the CIBA request. The teller receives confirmation that the customer has signed in and the wire transfer proceeds as expected.

By following the CIBA spec, we can develop a standards-compliant way to handle this wire transfer scenario, allowing both users to sign into the same system on their own devices.

Tip: For organizations implementing Financial-grade compliance via FAPI conformance, you'll likely want to consider CIBA.

What does the CIBA flow look like?

As explained previously, CIBA helps coordinate an authentication request between multiple parties across different devices. A simple CIBA flow is distributed across 3 applications. In this case, we see a client application used by the bank teller, a client application used by the customer, and Duende IdentityServer.

The teller’s client initiates the flow by calling Duende IdentityServer, which stores the request. A custom event alerts the user to act on the request (email, text message, app notification, etc). Duende IdentityServer then waits for a user’s response from their client. While waiting for the request to complete, the teller client polls Duende IdentityServer for the result of the user’s choice. Polling continues until a response is received.

---
config:
  theme: default
  gantt:
    useWidth: 800
    useMaxWidth: false
---
sequenceDiagram
    Teller Client->>+Duende IdentityServer: Initiate CIBA Request
    Duende IdentityServer->>+Customer Device: Custom `IBackchannelAuthenticationUserNotificationService` implementation
    Teller Client-->>+Duende IdentityServer: Poll CIBA Request Result
    Teller Client-->>+Duende IdentityServer: Poll CIBA Request Result
    Teller Client-->>+Duende IdentityServer: Poll CIBA Request Result
    Customer Device->>+Duende IdentityServer: Approves/Rejects CIBA request
    Teller Client-->>+Duende IdentityServer: Poll CIBA Request Result
    Duende IdentityServer->>Teller Client: Return CIBA Request Result

You can find a more technical diagram that highlights which parts you must implement yourself in our documentation.

How do we code a CIBA Flow?

We have a full demo application available in our samples. The sample includes an API, Duende IdentityServer, and a console client to initiate and respond to the CIBA requests. In this post, we’ll cover the basics of the code required to set up a CIBA flow using Duende IdentityServer.

Note: After the CIBA flow begins, and the request has been registered with Duende IdentityServer, you will want to notify the user.

This is not part of the specification or Duende IdentityServer, and is something you must implement yourself via the IBackchannelAuthenticationUserNotificationService interface. The notification can be an e-mail, app notification, singing telegram, or any other way you choose to alert the user.

Setting Up Duende IdentityServer

We recommend starting with the Duende IdentityServer project templates, with the quickstart or basic setups. For this sample, we create the application-specific scope my_wire_transfer_scope, which will be included in the access token given to the teller after the CIBA request is accepted.

builder.Services.AddIdentityServer()
    .AddInMemoryIdentityResources([
        new IdentityResources.OpenId(),
        new IdentityResources.Profile()
    ])
    .AddInMemoryApiScopes([
        new ApiScope("my_wire_transfer_scope")
    ]);

Next up, a client object is added to Duende IdentityServer to enable the bank teller’s application to communicate with Duende IdentityServer. The AllowedGrantTypes uses the Duende.IdentityServer.Models.Ciba static value from the Duende.IdentityServer library. Also note that our my_wire_transfer_scope scope is in the list of allowed scopes.

idsvrBuilder.AddInMemoryClients(new Client[] {
    new Client
    {
        ClientId = "my-ciba-client",
        ClientName = "CIBA Client",
        ClientSecrets = 
        {
            new Secret("my-client-secret".Sha256())
        },
        AllowedGrantTypes = GrantTypes.Ciba,
        AllowedScopes =
        {
            IdentityServerConstants.StandardScopes.OpenId,
            IdentityServerConstants.StandardScopes.Profile,
            "my_wire_transfer_scope",
        }
    }
});

That’s all we need on the Duende IdentityServer side to accept and track CIBA requests for a given client application. We’ll come back to this application later.

Setting Up the Initiating Client

The initiating client is the software used by the bank teller to request the customer authenticate for the wire transfer. In the real world, this would be a website or a mobile app, but we can create a console application to simulate it. We start by creating the console application and adding the Duende.IdentityModel library.

dotnet add package Duende.IdentityModel

The console application needs to tell Duende IdentityServer that we want our hard-coded customer, alice, to authenticate. This is done using a BackchannelAuthenticationRequest object, and Duende IdentityServer will track the request until the customer acts on it. Notice the request includes our custom my_wire_transfer_scope scope. Also notice the BindingMessage property is set to a GUID. This string is displayed to the user so they can verify the message they are accepting.

using Duende.IdentityModel;
using Duende.IdentityModel.Client;

var httpClient = new HttpClient();
var cache = new DiscoveryCache("https://localhost:5001");
var disco = await cache.GetAsync();
if (disco.IsError) throw new Exception(disco.Error);

var req = new BackchannelAuthenticationRequest()
{
    Address = disco.BackchannelAuthenticationEndpoint,
    ClientId = "my-ciba-client",
    ClientSecret = "my-client-secret",
    Scope = "openid profile my_wire_transfer_scope",
    BindingMessage = Guid.NewGuid().ToString("N").Substring(0, 10),
    LoginHint = "alice",
    RequestedExpiry = 200
};

var bcResp = await httpClient.RequestBackchannelAuthenticationAsync(req);

if (bcResp.IsError) 
    throw new Exception(bcResp.Error);

Once the backchannel request completes successfully, the console application can wait for the customer to acknowledge the request. We’ll add a while() loop to continuously poll Duende IdentityServer to check the request’s status, and exit once we receive a non-errored response.

We’re using a console application to simplify the demo. In a production scenario, you will want to notify the user about the CIBA request using your custom implementation via IBackchannelAuthenticationUserNotificationService.

TokenResponse? tokenResponse = null;

while (tokenResponse is null)
{
    var bcTokenResponse = await 
        httpClient.RequestBackchannelAuthenticationTokenAsync(
            new BackchannelAuthenticationTokenRequest
            {
                Address = disco.TokenEndpoint,
                ClientId = "my-ciba-client",
                ClientSecret = "my-client-secret",
                AuthenticationRequestId = bcResp.AuthenticationRequestId
            });

    if (bcTokenResponse.IsError)
    {
        var error = bcTokenResponse.Error;
        if (error == OidcConstants.TokenErrors.AuthorizationPending
            || error == OidcConstants.TokenErrors.SlowDown)
        {
            Console.WriteLine($"{bcTokenResponse.Error}...waiting.");
            Thread.Sleep(bcResp.Interval.Value * 1000);
        }
        else
        {
            throw new Exception(bcTokenResponse.Error);
        }
    }
    else
    {
        tokenResponse = bcTokenResponse;
    }
}

Once the console application receives a valid response from Duende IdentityServer, we have a valid access token for the customer that we can use in any HTTP requests we make. In this example, the teller’s client application is making an HTTP request to an API using the customer’s access token:

var apiClient = new HttpClient();
apiClient.SetBearerToken(tokenResponse.AccessToken);
var response = await apiClient.GetStringAsync("https://localhost:5002/identity");

Console.WriteLine(response);

Note: The SetBearerToken extension method on HttpClient comes from the Duende.IdentityModel NuGet package. It is used for convenience to place the access token in the needed HTTP header. See another example here.

Accept the Backchannel Request

While the console application waits in a polling loop, the customer will sign into our application asynchronously and approve the request. The remaining code snippets are for the Duende IdentityServer project we set up above.

We’ll only look at the code required to accept the backchannel authentication request. If you want a full example, the demo application implements this inside the Duende IdentityServer ASP.NET Core project.

List Pending Requests

To retrieve a list of pending backchannel authentication requests for the customer, the IBackchannelAuthenticationInteractionService service provides the GetPendingLoginRequestsForCurrentUserAsync() method to retrieve the pending requests for the signed-in customer. You can then display each request in the user’s interface.

var logins = await _backchannelAuthenticationInteraction
    .GetPendingLoginRequestsForCurrentUserAsync();

CIBA - Pending Requests

Once you have the customer’s input to accept or reject the CIBA request, you can use the IBackchannelAuthenticationInteractionService service to load the request, and then mark it completed with the CompleteLoginRequestAsync() method. You can represent this to the user in any UI you implement.

private readonly IBackchannelAuthenticationInteractionService _interaction;

var request = await _interaction.GetLoginRequestByInternalIdAsync(requestId);

var result = new CompleteBackchannelLoginRequest(Input.Id)
{
    ScopesValuesConsented = ["openid", "profile", "my_wire_transfer_scope"]
};

await _interaction.CompleteLoginRequestAsync(result);

CIBA - Request Permission

Conclusion

In this post, we covered how complex and useful CIBA is in high-trust environments. We also saw what’s required to code up a solution using Duende IdentityServer. Understanding the asynchronous stages of the CIBA standard is vital to creating a seamless solution for your customers.

To learn more, we recommend reading our documentation and experimenting with our sample to implement a CIBA flow. We also have a great community of developers ready to help you discuss and understand all there is to know about securing your .NET applications.

As always, thank you for reading, and please leave any comments below.