Client-Initiated Backchannel Authentication (CIBA) is a new OpenID Connect specification that describes decoupled authentication flows. This article describes how to implement a CIBA flow inside Duende IdentityServer.
What is CIBA?
Traditionally, users access client applications on the same device they authenticate with OpenID Connect providers, usually via redirects in a web application.
These traditional flows are now known as coupled authentication flows, wherein a user initiates an authentication request and authenticates on the same device they're accessing the client application on.
CIBA introduces the idea of decoupled flows wherein the user's authentication device is decoupled from the client application. Moreover, decoupled flows allow for the client application to initiate the authentication request.
For example, a call centre employee may be talking to a customer over the phone and may need them to authenticate themselves before proceeding. Normally, this process would be done via the answering of some security questions over the phone. However, using CIBA, the call centre employee just needs to initiate a CIBA authentication request on behalf of the customer. The customer on the other end of the line may then get a text or push notification asking them to confirm their identity. Assuming successful authentication, the call centre employee can proceed with the call.
Note: The CIBA flow can only be used for confidential clients
Backchannel authentication request endpoint
We will use the sample Duende IdentityServer with CIBA user interaction pages as a starting point.
The new backchannel authentication request endpoint is the first new addition to your IdentityServer you'll find when working with the CIBA flow.
You can view the value for the new endpoint by querying your IdentityServer's Discovery Document.
"issuer": "https://localhost:5001",
"jwks_uri":"https://localhost:5001/.well-known/openid-configuration/jwks",
"authorization_endpoint": "https://localhost:5001/connect/authorize",
"token_endpoint": "https://localhost:5001/connect/token",
"userinfo_endpoint": "https://localhost:5001/connect/userinfo",
"end_session_endpoint": "https://localhost:5001/connect/endsession",
"check_session_iframe": "https://localhost:5001/connect/checksession",
"revocation_endpoint": "https://localhost:5001/connect/revocation",
"intospection_endpoint": "https://localhost:5001/connect/introspect",
"device_authorization_endpoint": "https://localhost:5001/connect/deviceauthorization",
"backchannel_authentication_endpoint": "https://localhost:5001/connect/ciba",
"frontchannel_logout_supported": true,
"frontchannel_logout_session_supported": true,
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
The backchannel_authentication_endpoint is the endpoint that our client application will call to initiate a CIBA request. The endpoint is protected in the same way as the token endpoint; meaning you'll need to authenticate your client against it using any authentication method configured for its client id.
It's important to note that any clients wishing to use this endpoint must be configured with the urn:openid:params:grant-type:ciba grant type, as this is the grant type used when polling the token endpoint for our access token, but we'll get to that later.
Additionally, because the CIBA flow extends upon OpenID Connect, you must send at least the openid scope in your backchannel authentication request.
We're also required to send precisely one of the following hint parameters.
- login_hint
This can typically take the form of a username or another user specific value that can be used to identify the end user
- id_token_hint
A previously issued id_token
- login_hint_token
A token containing the necessary information used to identify a user
Why do we need hints?
Since CIBA decouples the authentication device and the client application, the OpenID Connect provider does not know which user needs to be authenticated. The client application needs to send a piece of identifying information that the OpenID Connect provider can use to identify the user that is to be authenticated.
Client Preparation
Before getting started, we need to configure a client within our IdentityServer that can authenticate users using CIBA. To make this easier, we will use the AdminUI client creation wizard which, as of AdminUI v6, includes the ability to create clients that use the CIBA grant type.
We can see two CIBA specific fields in the updated client wizard. These being:
- CIBA Lifetime: This is the amount of time a CIBA request can remain valid without user authentication.
- Polling interval: This is the amount of time a client application has to wait betwen requests to the token endpoint to check if the user has authenticated themselves.
Initiating a CIBA Authentication Request from a Client
Now that our CIBA client is configured, we need to lay the groundwork for the first part of CIBA. That being the CIBA Request to the Backchannel Authentication Endpoint.
To construct this request, we first need to know the backchannel authentication endpoint. We can get this information from our IdentityServer's Discovery Document.
var disco = await _cache.GetAsync();
if (disco.IsError) throw new Exception(disco.Error);
var backchannelAuthenticationEndpoint = disco.BackchannelAuthenticationEndpoint;
After we have our endpoint, we can construct our BackchannelAuthenticationRequest
to send to our Backchannel Authentication Endpoint on our IdentityServer.
Our LoginHint
is the username of a user we have in our user store.
We have also included the openid scope.
After this, all that's left to do is call our IdentityServer's Backchannel Authentication Endpoint with our BackchannelAuthenticationRequest
object.
var req = new BackchannelAuthenticationRequest()
{
Address = backchannelAuthenticationEndpoint,
ClientId = "ciba",
ClientSecret = "secret",
Scope = "openid"
LoginHint = "alice"
};
var client = new HttpClient();
var response = await client.RequestBackchannelAuthenticationAsync(req);
After making this request successfully, we get back an AuthenticationRequestId
. We need to use this right at the end of the flow when our client is trying to obtain the access token by polling the IdentityServer token endpoint.
However, before our IdentityServer can process this request, we need to implement a couple of things.
IdentityServer Request Preparation
CIBA User Validator
The first bit of implementation-specific code we need to write to process CIBA requests is to implement the IBackchannelAuthenticationUserValidator
interface and add it to our Dependency Injection (DI) pipeline
This interface contains the ValidateRequestAsync
method that is called during CIBA requests.
The ValidateRequestAsync
method returns a BackchannelAuthenticationUserValidatonResult
which, if successful, should return the authenticating user's ClaimPrincipal
. However, if validation is not successful, we can populate the Error
and ErrorDescription
fields to return information as to why the request failed to the client.
The ValidateRequestAsync
method takes a BackChannelAuthenticationUserValidator
object as a parameter. Peering into this object we can see that it contains all the necessary information we need to verify and validate the user the client is requesting authentication for.
/// <summary>
/// Context information for validating a user during backchannel authentication request.
/// </summary>
public class BackchannelAuthenticationUserValidatorContext
{
/// <summary>
/// Gets or sets the client.
/// </summary>
public Client Client { get; set; }
/// <summary>
/// Gets or sets the login hint token.
/// </summary>
public string LoginHintToken { get; set; }
/// <summary>
/// Gets or sets the id token hint.
/// </summary>
public string IdTokenHint { get; set; }
/// <summary>
/// Gets or sets the validated claims from the id token hint.
/// </summary>
public IEnumerable<Claim> IdTokenHintClaims { get; set; }
///<summary>
/// Gets or sets the login hint.
/// </summary>
public string LoginHint { get; set; }
/// <summary>
/// Gets or sets the user code.
/// </summary>
public string UserCode { get; set; }
/// <summary>
/// Gets or sets the binding message.
/// </summary>
public string BindingMessage { get; set; }
}
Using this information, we can verify that the hint being passed up by the client in the authentication request corresponds to a valid user in our user store.
var user = TestUsers.Users
.FirstOrDefault(user => user.Username == userValidatorContext.LoginHint);
If our user is null, then we can assume that either the user does not exist in our user store, or the LoginHint
passed up by the client is invalid in some way. In either instance, we want to populate the Error
and ErrorDescription
fields in our request object.
if (user == null)
{
result.Error = "invalid_user";
result.ErrorDescription = "no such user";
}
Otherwise, if the user is not null, we can populate the Subject
field in our response object with a ClaimsPrincipal
constructed using the Claims
collection on our user. Remember, we also need to include the user's SubjectId (sub) claim.
//We need to return the user's sub claim as part of the ClaimsPrincipal
user.Claims.Add(new Claim("sub", user.SubjectId));
result = new BackchannelAuthenticationUserValidatonResult()
{
Subject = new ClaimsPrincipal(new ClaimsIdentity(user.Claims))
};
We can now return our BackchannelAuthenticationUserValidatonResult
and add our interface implementation to the DI pipeline.
services.AddScoped<IBackchannelAuthenticationUserValidator, BackchannelAuthenticationUserValidator>();
CIBA Notification Service
The next piece of the CIBA request pipeline we need to implement is the delegation of user authentication to the authenticating user's trusted device.
To do this, we need to implement another interface. This time it's the IBackchannelAuthenticationUserNotificationService
. This is the service that's used to contact the authenticating user when a CIBA authentication request has been made successfully.
This interface contains the SendLoginRequestAsync
method, which takes a BackchannelUserLoginRequest
object as a parameter. This object contains all the contextual information needed to send to the user. Most importantly, it contains the InternalId
, which is the identifier needed to complete the request in the following section.
For this example, I'm going to be sending an email to the authenticating user using SendGrid. However, the beauty of CIBA is that the delivery method to the user's authentication device is entirely up to you, the implementer. You may choose to send an email, push notification, carrier pigeon, or text message.
Firstly, we need to pull out the relevant information from the BackchannelUserLoginRequest
object. In our case, we want to pull out the user's name and email. We'll also pull out the authenticating client's name too, just to flesh out the information presented to the user in the email.
var authenticatingUserEmail = request.Subject.Claims
.FirstOrDefault(claim => claim.Type == JwtClaimTypes.Email).Value;
var authenticatingUserName = request.Subject.Claims
.FirstOrDefault(claim => claim.Type == JwtClaimTypes.Name).Value;
var backchannelLoginId = request.InternalId;
With this information, we can construct an email containing a link to a page in our IdentityServer that will handle the consent for us.
User Consent Screen
The user consent screens are provided to us in the sample. However, I thought I'd take a minute to outline how they work.
Initial Page
In our scenario, the first page a user will see will be the Index page provided by the sample. In this page's backend, we check to see that the InternalId
provided to the page in the form of a URL query parameter is a valid CIBA login request.
LoginRequest = await _backchannelAuthenticationInteraction.GetLoginRequestByInternalIdAsync(id);
The validity of the request is tied to the CibaLifetime
value that is configured with our client. If we have set the value to 100 (seconds) after the request has been completed, then a user clicking on the email link after these 100 seconds have elapsed will be presented with an error.
If our InternalId matches a valid login request, then we can allow them to view the page.
An important thing to note is that you may also want to display a Binding Message on this page that ensures that the authentication request initiated by the client device is the same that triggers the action on the authentication device. Moreover, you may also want to use a User Code, which is a unique code known only by the Client and Authorization Server that prevents unauthorized authentication requests being sent to the user. These two fields are optional, but if needed can be included in your Authentication Request.
After the user has continued through the Index page, if they don't have an existing session on our IdentityServer, they will be prompted to log in. Afterwards, they'll be shown a typical scope consent page.
This page works in the same way as any other consent screen. After the user has consented to our client's access requirements, we can construct a CompleteBackchannelLoginRequest
object. We need to make sure we pass the correct InternalId
into the class constructor too, otherwise, we won't be able to retrieve the access token from our client. We'll also construct our object with the description the user provided in the consent screen, as well as the scopes they consented to.
result = new CompleteBackchannelLoginRequest(Input.Id) {
ScopesValuesConsented = scopes.ToArray(),
Description = Input.Description
};
After constructing our CompleteBackchannelLoginRequest
object, we can call the CompleteLoginRequestAsync
method on the IBackchannelAuthenticationInteractionService
interface that is injected into the page for us. Calling this method tells our IdentityServer that our CIBA Request is complete.
await _interaction.CompleteLoginRequestAsync(result);
Getting the Access Token
While all the above is happening, our client needs to be actively waiting and listening for IdentityServer to supply either the access token or a reason to why the access token can't be provided.
The CIBA specification outlines three ways in which a client can be provided with an access token. Poll, Ping, and Push. However, as of the time of writing, IdentityServer only has support for the Poll token retrieval mode.
Polling from the Client
To retrieve our access token when it's ready from our IdentityServer, we need to poll the token endpoint using the CIBA grant type.
Thankfully, this functionality is built into IdentityModel in the form of the RequestBackchannelAuthenticationTokenAsync
method. Using this method inside a while loop allows us to poll our IdentityServer regularly until we either receive an error response, an Authorization Pending response, or a Slow Down response.
var disco = await _cache.GetAsync();
if (disco.IsError) throw new Exception(disco.Error);
var client = new HttpClient();
while (true)
{
var response = await client.RequestBackchannelAuthenticationTokenAsync(new BackchannelAuthenticationTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = "ciba",
ClientSecret = "secret",
AuthenticationRequestId = authorizeResponse.AuthenticationRequestId
});
if (response.IsError)
{
if (response.Error == OidcConstants.TokenErrors.AuthorizationPending || response.Error == OidcConstants.TokenErrors.SlowDown)
{
Console.WriteLine(${response.Error}...waiting.");Thread.Sleep(authorizeResponse.Interval.Value * 5000);
}
else
{
throw new Exception(response.Error);
}
}
else
{
return response;
}
await Task.Delay(5000);
}
While inside the loop, we want to send BackchannelAuthenticationTokenRequest
s to our token endpoint using the AuthenticationRequestId
provided to us after our initial CIBA Request.
We can get two error responses back from our IdentityServer while polling the token endpoint. Firstly, the AuthorizationPending token error. This response simply means that IdentityServer is waiting for the user to complete the authentication process. The SlowDown response is returned when our OpenID Connect provider wants us to... slow down... our requests to its token endpoint. The rate at which you poll the token endpoint should be in line with the PollingInterval you have set on your client. However, if you do receive the SlowDown response, you must increase the polling interval by at least 5 seconds.
If all goes well and the user authenticates and grants permission within the lifetime of the authentication request, you should receive an access token from the token endpoint while polling.
Conclusion
That's it! That's CIBA in action.
In this article we've learned about CIBA, how it's different from traditional OpenID Connect authentication flows, and how we can implement it in IdentityServer using the CIBA User Interaction sample.
If you're looking for a way to easily configure CIBA clients, as well as any other OAuth/OpenID Connect client. Or even if you're looking for a straightforward way to manage your IdentityServer, why not check out AdminUI?