From 93c6fa8347496348d837ed915b041959ed32a75f Mon Sep 17 00:00:00 2001 From: kailash-b Date: Mon, 9 Dec 2024 21:05:37 +0530 Subject: [PATCH] Adds support for CIBA --- .../AuthenticationApiClient.cs | 54 +++++ .../HttpClientAuthenticationConnection.cs | 11 +- .../IAuthenticationApiClient.cs | 23 +++ ...nitiatedBackchannelAuthorizationRequest.cs | 53 +++++ ...itiatedBackchannelAuthorizationResponse.cs | 28 +++ ...tedBackchannelAuthorizationTokenRequest.cs | 33 +++ ...edBackchannelAuthorizationTokenResponse.cs | 10 + .../Models/Ciba/LoginHint.cs | 24 +++ ...tInitiatedBackchannelAuthorizationTests.cs | 191 ++++++++++++++++++ 9 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationRequest.cs create mode 100644 src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationResponse.cs create mode 100644 src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenRequest.cs create mode 100644 src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenResponse.cs create mode 100644 src/Auth0.AuthenticationApi/Models/Ciba/LoginHint.cs create mode 100644 tests/Auth0.AuthenticationApi.IntegrationTests/ClientInitiatedBackchannelAuthorizationTests.cs diff --git a/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs b/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs index ef3bc47f7..fa132568a 100644 --- a/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs +++ b/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Auth0.AuthenticationApi.Models.Ciba; namespace Auth0.AuthenticationApi { @@ -497,6 +498,59 @@ public Task PushedAuthorizationRequestAsync( ); } + /// + public Task ClientInitiatedBackchannelAuthorization(ClientInitiatedBackchannelAuthorizationRequest request, + CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var body = new Dictionary { + { "client_id", request.ClientId }, + { "binding_message", request.BindingMessage }, + { "login_hint", request.LoginHint.ToString() } + }; + + body.AddIfNotEmpty("scope", request.Scope); + body.AddIfNotEmpty("audience", request.Audience); + + body.AddAll(request.AdditionalProperties); + + ApplyClientAuthentication(request, body, true); + + return connection.SendAsync( + HttpMethod.Post, + BuildUri("/bc-authorize"), + body, + cancellationToken: cancellationToken + ); + } + + /// + public async Task GetTokenAsync( + ClientInitiatedBackchannelAuthorizationTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var body = new Dictionary + { + {"grant_type", "urn:openid:params:grant-type:ciba"}, + {"auth_req_id", request.AuthRequestId}, + {"client_id", request.ClientId} + }; + ApplyClientAuthentication(request, body, true); + var response = await connection.SendAsync( + HttpMethod.Post, + tokenUri, + body, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + return response; + } + /// /// Disposes of any owned disposable resources such as a . /// diff --git a/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs b/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs index 258fc26f8..775d14066 100644 --- a/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs +++ b/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs @@ -87,12 +87,17 @@ private async Task SendRequest(HttpRequestMessage request, CancellationTok var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - return typeof(T) == typeof(string) - ? (T)(object)content - : JsonConvert.DeserializeObject(content, jsonSerializerSettings); + return DeserializeContent(content); } } + internal T DeserializeContent(string content) + { + return typeof(T) == typeof(string) + ? (T)(object)content + : JsonConvert.DeserializeObject(content, jsonSerializerSettings); + } + private void ApplyHeaders(HttpRequestMessage request, IDictionary headers) { if (headers == null) return; diff --git a/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs b/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs index 268ad63eb..ba6866378 100644 --- a/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs +++ b/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Auth0.AuthenticationApi.Models.Ciba; namespace Auth0.AuthenticationApi { @@ -180,5 +181,27 @@ public interface IAuthenticationApiClient : IDisposable /// a with the details of the response. Task PushedAuthorizationRequestAsync(PushedAuthorizationRequest request, CancellationToken cancellationToken = default); + + /// + /// Initiates a Client Initiated Backchannel Authorization flow. + /// + /// + /// + /// representing the async operation containing + /// a with the details of the response. + Task ClientInitiatedBackchannelAuthorization(ClientInitiatedBackchannelAuthorizationRequest request, + CancellationToken cancellationToken = default); + + /// + /// Requests an Access Token using the CIBA flow + /// + /// + /// The cancellation token to cancel operation. + /// representing the async operation containing + /// a with the requested tokens. + /// + /// This must be polled while the user is completing their part of the flow at an interval no more frequent than that returned by . + /// + Task GetTokenAsync(ClientInitiatedBackchannelAuthorizationTokenRequest request, CancellationToken cancellationToken = default); } } diff --git a/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationRequest.cs b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationRequest.cs new file mode 100644 index 000000000..de61603e5 --- /dev/null +++ b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationRequest.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using Microsoft.IdentityModel.Tokens; + +namespace Auth0.AuthenticationApi.Models.Ciba +{ + /// + /// Contains information required for initiating a CIBA authorization request. + /// + public class ClientInitiatedBackchannelAuthorizationRequest : IClientAuthentication + { + /// + public string ClientId { get; set; } + + /// + public string ClientSecret { get; set; } + + /// + public SecurityKey ClientAssertionSecurityKey { get; set; } + + /// + public string ClientAssertionSecurityKeyAlgorithm { get; set; } + + /// + /// A human-readable string intended to be displayed on both the device calling /bc-authorize and + /// the user’s authentication device (e.g. phone) to ensure the user is approving the correct request. + /// For example: “ABC-123-XYZ”. + /// + public string BindingMessage { get; set; } + + /// + public LoginHint LoginHint { get; set; } + + /// + /// A space-separated list of OIDC and custom API scopes. + /// + public string Scope { get; set; } + + /// + /// If you require an access token for an API, pass the unique identifier of the target API you want to access here + /// + public string Audience { get; set; } + + /// + /// Used to configure a custom expiry time for this request. + /// + public int? RequestExpiry { get; set; } + + /// + /// Any additional properties to use. + /// + public IDictionary AdditionalProperties { get; set; } = new Dictionary(); + } +} \ No newline at end of file diff --git a/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationResponse.cs b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationResponse.cs new file mode 100644 index 000000000..e5e301081 --- /dev/null +++ b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationResponse.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; + +namespace Auth0.AuthenticationApi.Models.Ciba +{ + /// + /// Contains information about the client initiated backchannel authorization (CIBA) response. + /// + public class ClientInitiatedBackchannelAuthorizationResponse + { + /// + /// Unique id of the authorization request. Can be used further to poll for /oauth/token. + /// + [JsonProperty("auth_req_id")] + public string AuthRequestId { get; set; } + + /// + /// Tells you how many seconds we have until the authentication request expires. + /// + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + /// + /// Tells how many seconds you must leave between poll requests. + /// + [JsonProperty("interval")] + public int Interval { get; set; } + } +} \ No newline at end of file diff --git a/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenRequest.cs b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenRequest.cs new file mode 100644 index 000000000..14d7d5f2d --- /dev/null +++ b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenRequest.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Microsoft.IdentityModel.Tokens; + +namespace Auth0.AuthenticationApi.Models.Ciba +{ + /// + /// Contains information required for request token using CIBA. + /// + public class ClientInitiatedBackchannelAuthorizationTokenRequest : IClientAuthentication + { + /// + public string ClientId { get; set; } + + /// + public string ClientSecret { get; set; } + + /// + public SecurityKey ClientAssertionSecurityKey { get; set; } + + /// + public string ClientAssertionSecurityKeyAlgorithm { get; set; } + + /// + /// Unique identifier of the authorization request. This value will be returned from the call to /bc-authorize. + /// + public string AuthRequestId { get; set; } + + /// + /// Any additional properties to use. + /// + public IDictionary AdditionalProperties { get; set; } = new Dictionary(); + } +} \ No newline at end of file diff --git a/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenResponse.cs b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenResponse.cs new file mode 100644 index 000000000..0d76d7dcd --- /dev/null +++ b/src/Auth0.AuthenticationApi/Models/Ciba/ClientInitiatedBackchannelAuthorizationTokenResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Auth0.AuthenticationApi.Models.Ciba +{ + public class ClientInitiatedBackchannelAuthorizationTokenResponse : AccessTokenResponse + { + [JsonProperty("scope")] + public string Scope { get; set; } + } +} \ No newline at end of file diff --git a/src/Auth0.AuthenticationApi/Models/Ciba/LoginHint.cs b/src/Auth0.AuthenticationApi/Models/Ciba/LoginHint.cs new file mode 100644 index 000000000..c49075428 --- /dev/null +++ b/src/Auth0.AuthenticationApi/Models/Ciba/LoginHint.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace Auth0.AuthenticationApi.Models.Ciba +{ + /// + /// Contains information about the user to contact for authentication. + /// + public class LoginHint + { + [JsonProperty("format")] + public string Format { get; set; } + + [JsonProperty("iss")] + public string Issuer { get; set; } + + [JsonProperty("sub")] + public string Subject { get; set; } + + public override string ToString() + { + return JsonConvert.SerializeObject(this); + } + } +} \ No newline at end of file diff --git a/tests/Auth0.AuthenticationApi.IntegrationTests/ClientInitiatedBackchannelAuthorizationTests.cs b/tests/Auth0.AuthenticationApi.IntegrationTests/ClientInitiatedBackchannelAuthorizationTests.cs new file mode 100644 index 000000000..f1e520305 --- /dev/null +++ b/tests/Auth0.AuthenticationApi.IntegrationTests/ClientInitiatedBackchannelAuthorizationTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; +using Newtonsoft.Json; +using FluentAssertions; +using Auth0.AuthenticationApi.Models.Ciba; +using Auth0.Core.Exceptions; +using Auth0.Tests.Shared; +using Xunit; + +namespace Auth0.AuthenticationApi.IntegrationTests; + +public class ClientInitiatedBackchannelAuthorizationTests : TestBase +{ + [Fact] + public async void Ciba_request_should_succeed() + { + var mockHandler = new Mock(MockBehavior.Strict); + var mockResponse = new ClientInitiatedBackchannelAuthorizationResponse() + { + AuthRequestId = "Random-Guid", + ExpiresIn = 300 + }; + + var mockTokenResponse = new ClientInitiatedBackchannelAuthorizationTokenResponse() + { + AccessToken = "This is a mock access_token", + IdToken = "This is a mock ID token", + ExpiresIn = 300, + Scope = "openid" + }; + var domain = GetVariable("AUTH0_AUTHENTICATION_API_URL"); + + SetupMockWith(mockHandler,$"https://{domain}/bc-authorize", JsonConvert.SerializeObject(mockResponse)); + SetupMockWith(mockHandler,$"https://{domain}/oauth/token", JsonConvert.SerializeObject(mockTokenResponse)); + + var httpClient = new HttpClient(mockHandler.Object); + var authenticationApiClient = new TestAuthenticationApiClient(domain, new TestHttpClientAuthenticationConnection(httpClient)); + + var response = await authenticationApiClient.ClientInitiatedBackchannelAuthorization( + new ClientInitiatedBackchannelAuthorizationRequest() + { + ClientId = GetVariable("AUTH0_CLIENT_ID"), + Scope = "openid profile", + ClientSecret = GetVariable("AUTH0_CLIENT_SECRET"), + BindingMessage = "BindingMessage", + LoginHint = new LoginHint() + { + Format = "iss_sub", + Issuer = "https://dx-sdks-testing.us.auth0.com/", + Subject = "auth0|6746de965777c7fc70547a11" + } + } + ); + + response.ExpiresIn.Should().Be(300); + response.AuthRequestId.Should().Be("Random-Guid"); + + var cibaTokenResponse = await authenticationApiClient.GetTokenAsync( + new ClientInitiatedBackchannelAuthorizationTokenRequest() + { + AuthRequestId = "Random-Guid", + ClientId = "ClientId", + ClientSecret = "ClientSecret" + } + ); + cibaTokenResponse.Should().BeEquivalentTo(cibaTokenResponse); + mockHandler.VerifyAll(); + } + + [Fact] + public async void Ciba_request_in_pending_state() + { + var mockHandler = new Mock(MockBehavior.Strict); + var mockResponse = new ClientInitiatedBackchannelAuthorizationResponse() + { + AuthRequestId = "Random-Guid", + ExpiresIn = 300 + }; + + var mockTokenResponse = + "{\"error\": \"authorization_pending\",\n\"error_description\": \"The end-user authorization is pending\"\n}"; + + var domain = GetVariable("AUTH0_AUTHENTICATION_API_URL"); + + SetupMockWith(mockHandler,$"https://{domain}/bc-authorize", JsonConvert.SerializeObject(mockResponse)); + SetupMockWith(mockHandler,$"https://{domain}/oauth/token", JsonConvert.SerializeObject(mockTokenResponse), HttpStatusCode.BadRequest); + + var httpClient = new HttpClient(mockHandler.Object); + var authenticationApiClient = new TestAuthenticationApiClient(domain, new TestHttpClientAuthenticationConnection(httpClient)); + + var response = await authenticationApiClient.ClientInitiatedBackchannelAuthorization( + new ClientInitiatedBackchannelAuthorizationRequest() + { + ClientId = GetVariable("AUTH0_CLIENT_ID"), + Scope = "openid profile", + ClientSecret = GetVariable("AUTH0_CLIENT_SECRET"), + BindingMessage = "BindingMessage", + LoginHint = new LoginHint() + { + Format = "iss_sub", + Issuer = "https://dx-sdks-testing.us.auth0.com/", + Subject = "auth0|6746de965777c7fc70547a11" + } + } + ); + + response.ExpiresIn.Should().Be(300); + response.AuthRequestId.Should().Be("Random-Guid"); + + var ex = await Assert.ThrowsAsync(() => authenticationApiClient.GetTokenAsync( + new ClientInitiatedBackchannelAuthorizationTokenRequest() + { + AuthRequestId = "Random-Guid", + ClientId = "ClientId", + ClientSecret = "ClientSecret" + } + )); + mockHandler.VerifyAll(); + } + + [Fact] + public async void Ciba_request_in_expired_or_rejected_state() + { + var mockHandler = new Mock(MockBehavior.Strict); + var mockResponse = new ClientInitiatedBackchannelAuthorizationResponse() + { + AuthRequestId = "Random-Guid", + ExpiresIn = 300 + }; + + var mockTokenResponse = + "{\n\"error\": \"access_denied\",\n\"error_description\": \"The end-user denied the authorization request or it\nhas been expired\"\n}"; + + var domain = GetVariable("AUTH0_AUTHENTICATION_API_URL"); + + SetupMockWith(mockHandler,$"https://{domain}/bc-authorize", JsonConvert.SerializeObject(mockResponse)); + SetupMockWith(mockHandler,$"https://{domain}/oauth/token", JsonConvert.SerializeObject(mockTokenResponse), HttpStatusCode.BadRequest); + + var httpClient = new HttpClient(mockHandler.Object); + var authenticationApiClient = new TestAuthenticationApiClient(domain, new TestHttpClientAuthenticationConnection(httpClient)); + + var response = await authenticationApiClient.ClientInitiatedBackchannelAuthorization( + new ClientInitiatedBackchannelAuthorizationRequest() + { + ClientId = GetVariable("AUTH0_CLIENT_ID"), + Scope = "openid profile", + ClientSecret = GetVariable("AUTH0_CLIENT_SECRET"), + BindingMessage = "BindingMessage", + LoginHint = new LoginHint() + { + Format = "iss_sub", + Issuer = "https://dx-sdks-testing.us.auth0.com/", + Subject = "auth0|6746de965777c7fc70547a11" + } + } + ); + + response.ExpiresIn.Should().Be(300); + response.AuthRequestId.Should().Be("Random-Guid"); + + var ex = await Assert.ThrowsAsync(() => authenticationApiClient.GetTokenAsync( + new ClientInitiatedBackchannelAuthorizationTokenRequest() + { + AuthRequestId = "Random-Guid", + ClientId = "ClientId", + ClientSecret = "ClientSecret" + } + )); + mockHandler.VerifyAll(); + } + + private static void SetupMockWith(Mock mockHandler, string domain, string stringContent, HttpStatusCode code = HttpStatusCode.OK) + { + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.RequestUri.ToString() == domain), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = code, + Content = new StringContent(stringContent, Encoding.UTF8, "application/json"), + }); + } +} \ No newline at end of file