diff --git a/EXAMPLES.md b/EXAMPLES.md index b67fd76..2219032 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -8,6 +8,8 @@ - [Roles](#roles) - [Backchannel Logout](#backchannel-logout) - [Blazor Server](#blazor-server) +- [Accessing Auth0.AuthenticationApi features](#accessing-auth0authenticationapi-features) +- [Accessing specific features like CIBA](#accessing-specific-features-like-ciba) ## Login and Logout Triggering login or logout is done using ASP.NET's `HttpContext`: @@ -444,3 +446,97 @@ public class LogoutModel : PageModel } } ``` + +## Accessing Auth0.AuthenticationApi features +`Auth0.AuthenticationApi` package is our standalone Authentication package that supports a wide range of +options for Authentication. For example, you can use it to implement the [client credentials flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow), as shown below : + +```csharp +/// Register the dependency in the container as below. +/// Program.cs / Startup.cs +builder.Services.AddAuth0WebAppAuthentication(options => +{ + options.Domain = "domain"; + options.ClientId = "ClientId"; + options.ClientSecret = "clientSecret"; +}).WithAuthenticationApiClient(); + + +/// Accessing the Api Client in the controller +public AccountController(IAuthenticationApiClient apiClient) +{ + _apiClient = apiClient; +} + +[Authorize] +public async Task LoginWithAuthenticationApi() +{ + await _apiClient.GetTokenAsync(new ClientCredentialsTokenRequest() + { + ClientId = "", + ClientSecret = "" + }, new CancellationToken()); +} +``` + +## Accessing specific features like CIBA +Although `Auth0.AuthenticationApi` package has a wide range of options for Authentication. +We can access the CIBA feature as below. It aims to make it easy to integrate into an appllication. + +```csharp +/// Register the dependency in the container as below. +/// Program.cs / Startup.cs +builder.Services.AddAuth0WebAppAuthentication(options => +{ + options.Domain = "domain"; // required + options.ClientId = "ClientId"; // required + options.ClientSecret = "clientSecret"; // required +}).WithClientInitiatedBackchannelAuthentication(); + + +/// Accessing the Auth0CibaService in the controller +public AccountController(IAuth0CibaService auth0CibaService) +{ + _auth0CibaService = auth0CibaService; +} + +public async Task InitiateLoginWithCiba(string returnUrl = "/") +{ + var response = await _auth0CibaService.InitiateAuthenticationAsync(new CibaInitiationRequest() + { + Scope = "openid profile", + BindingMessage = "BindingMessage", + LoginHint = new LoginHint() + { + Format = "iss_sub", + Issuer = "https://your-domain/", + Subject = "userId" + } + }); + + // Cache the details for polling. + TempData["AuthRequestId"] = response.AuthRequestId; + TempData["CibaInitiationDetails"] = JsonSerializer.Serialize(response); + return RedirectToAction("Waiting"); +} + +// You could use the built-in polling mechanism or could implement your own polling mechanism by +// accessing the `GetTokenAsync` method using the AuthenticationApiClient as shown in the example before. +[HttpGet] +public async Task CheckCibaStatus() +{ + var cibaInitiateResponse = JsonSerializer.Deserialize(TempData["CibaInitiationDetails"]?.ToString() ?? string.Empty); + var authRequestId = cibaInitiateResponse?.AuthRequestId; + if (string.IsNullOrEmpty(authRequestId)) + { + return Json(new { isError = true }); + } + + var status = await _auth0CibaService.PollForTokensAsync(cibaInitiateResponse); + + if (status.IsSuccessful) + { + // Parse the accessToken / IdToken and use it as required. + } +} +``` diff --git a/src/Auth0.AspNetCore.Authentication/Auth0.AspNetCore.Authentication.csproj b/src/Auth0.AspNetCore.Authentication/Auth0.AspNetCore.Authentication.csproj index 603e415..0e16ecc 100644 --- a/src/Auth0.AspNetCore.Authentication/Auth0.AspNetCore.Authentication.csproj +++ b/src/Auth0.AspNetCore.Authentication/Auth0.AspNetCore.Authentication.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs b/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs index 945ac05..30c4c78 100644 --- a/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs +++ b/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs @@ -5,6 +5,9 @@ using System; using System.Threading.Tasks; using Auth0.AspNetCore.Authentication.BackchannelLogout; +using Auth0.AspNetCore.Authentication.ClientInitiatedBackChannelAuthentication; +using Auth0.AuthenticationApi; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Auth0.AspNetCore.Authentication { @@ -61,6 +64,31 @@ public Auth0WebAppAuthenticationBuilder WithBackchannelLogout() return this; } + /// + /// Configures the IAuthenticationApiClient to leverage Auth0.AuthenticationApi + /// + /// + public Auth0WebAppAuthenticationBuilder WithAuthenticationApiClient() + { + _services.AddSingleton( + _ => new AuthenticationApiClient(new Uri($"https://{_options.Domain}"))); + _services.AddTransient(); + return this; + } + + /// + /// Configures the IAuth0CibaService to leverage the CIBA features. + /// + /// + public Auth0WebAppAuthenticationBuilder WithClientInitiatedBackchannelAuthentication() + { + _services.TryAddSingleton( + _ => new AuthenticationApiClient(new Uri($"https://{_options.Domain}"))); + _services.TryAddScoped(); + + return this; + } + private void EnableWithAccessToken(Action configureOptions) { var auth0WithAccessTokensOptions = new Auth0WebAppWithAccessTokenOptions(); diff --git a/src/Auth0.AspNetCore.Authentication/ClientInitiatedBackChannelAuthentication/Auth0CibaService.cs b/src/Auth0.AspNetCore.Authentication/ClientInitiatedBackChannelAuthentication/Auth0CibaService.cs new file mode 100644 index 0000000..84013f2 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/ClientInitiatedBackChannelAuthentication/Auth0CibaService.cs @@ -0,0 +1,131 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Auth0.AuthenticationApi; +using Auth0.AuthenticationApi.Models.Ciba; +using Auth0.Core.Exceptions; + +namespace Auth0.AspNetCore.Authentication.ClientInitiatedBackChannelAuthentication; + +internal class Auth0CibaService : IAuth0CibaService +{ + private readonly IAuthenticationApiClient _authenticationApiClient; + private readonly Auth0WebAppOptions _options; + private readonly ILogger _logger; + + /// + /// Initiates an instance of Auth0CibaService which can be used to execute the CIBA workflow. + /// + /// Instance of + /// + /// + public Auth0CibaService( + IAuthenticationApiClient authenticationApiClient, + IOptions optionsAccessor, + ILogger logger) + { + _authenticationApiClient = authenticationApiClient; + _options = optionsAccessor.Value; + _logger = logger; + } + + /// + public async Task InitiateAuthenticationAsync( + CibaInitiationRequest request) + { + try + { + var cibaRequest = new ClientInitiatedBackchannelAuthorizationRequest + { + ClientId = _options.ClientId, + ClientSecret = _options.ClientSecret, + ClientAssertionSecurityKey = _options.ClientAssertionSecurityKey, + ClientAssertionSecurityKeyAlgorithm = _options.ClientAssertionSecurityKeyAlgorithm, + Audience = request.Audience, + LoginHint = request.LoginHint, + Scope = request.Scope, + RequestExpiry = request.RequestExpiry, + AdditionalProperties = request.AdditionalProperties, + BindingMessage = request.BindingMessage, + }; + + _logger.LogInformation("Initiating CIBA request!"); + var response = await _authenticationApiClient.ClientInitiatedBackchannelAuthorization(cibaRequest); + + return new CibaInitiationDetails() + { + AuthRequestId = response.AuthRequestId, + ExpiresIn = response.ExpiresIn, + Interval = response.Interval, + IsSuccessful = true, + ErrorMessage = null + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initiating CIBA request"); + throw; + } + } + + /// + public async Task PollForTokensAsync( + CibaInitiationDetails initDetails, CancellationToken cancellationToken) + { + var request = new ClientInitiatedBackchannelAuthorizationTokenRequest() + { + ClientId = _options.ClientId, + ClientSecret = _options.ClientSecret, + ClientAssertionSecurityKey = _options.ClientAssertionSecurityKey, + ClientAssertionSecurityKeyAlgorithm = _options.ClientAssertionSecurityKeyAlgorithm, + AuthRequestId = initDetails.AuthRequestId + }; + + var completionDetails = new CibaCompletionDetails() + { + IsSuccessful = false, + IsAuthenticationPending = true + }; + + while (completionDetails is { IsAuthenticationPending: true, IsSuccessful: false }) + { + _logger.LogDebug($"Polling CIBA token endpoint for auth_req_id: {initDetails.AuthRequestId} "); + try + { + var response = await _authenticationApiClient.GetTokenAsync(request, cancellationToken); + + completionDetails.AccessToken = response.AccessToken; + completionDetails.IdToken = response.IdToken; + completionDetails.TokenType = response.TokenType; + completionDetails.Scope = response.Scope; + completionDetails.ExpiresIn = response.ExpiresIn; + completionDetails.RefreshToken = response.RefreshToken; + completionDetails.IsSuccessful = true; + completionDetails.IsAuthenticationPending = false; + } + catch (ErrorApiException ex) + { + _logger.LogWarning( + ex, + $"CIBA polling error for auth_req_id: {initDetails.AuthRequestId}." + + $" Error: {ex.ApiError.Error}, Description: {ex.ApiError.Message}"); + + if (ex.ApiError.Error.Contains("authorization_pending", StringComparison.OrdinalIgnoreCase)) + { + await Task.Delay(TimeSpan.FromSeconds(initDetails.Interval)); + continue; + } + + completionDetails.IsAuthenticationPending = false; + completionDetails.Error = ex.ApiError.Error; + completionDetails.ErrorMessage = ex.ApiError.Message; + completionDetails.IsSuccessful = false; + } + } + return completionDetails; + } +} \ No newline at end of file diff --git a/src/Auth0.AspNetCore.Authentication/ClientInitiatedBackChannelAuthentication/IAuth0CibaService.cs b/src/Auth0.AspNetCore.Authentication/ClientInitiatedBackChannelAuthentication/IAuth0CibaService.cs new file mode 100644 index 0000000..3995c4b --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/ClientInitiatedBackChannelAuthentication/IAuth0CibaService.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Auth0.AuthenticationApi.Models.Ciba; + +namespace Auth0.AspNetCore.Authentication.ClientInitiatedBackChannelAuthentication; + +public class CibaInitiationDetails : ClientInitiatedBackchannelAuthorizationResponse +{ + /// + /// Indicates whether the polling was successful. + /// + public bool IsSuccessful { get; init; } = true; + + /// + /// Indicates any errors that occurred during the initiation of the CIBA request. + /// + public string? ErrorMessage { get; init; } +} + +public class CibaInitiationRequest +{ + /// + public string? BindingMessage { get; set; } + + /// + public LoginHint? LoginHint { get; set; } + + /// + public string? Scope { get; set; } + + /// + public string? Audience { get; set; } + + /// + public int? RequestExpiry { get; set; } + + /// + public IDictionary AdditionalProperties { get; set; } = new Dictionary(); +} + +public class CibaCompletionDetails : ClientInitiatedBackchannelAuthorizationTokenResponse +{ + /// + /// Signifies if the authentication is pending. + /// + public bool IsAuthenticationPending { get; set; } = true; + + /// + /// Signifies if the authentication is successful. + /// + public bool IsSuccessful { get; set; } = false; + + /// + /// The error received in case of expiry or consent rejection + /// + public string? Error { get; set; } + + /// + /// The error message received in case of expiry or consent rejection + /// + public string? ErrorMessage { get; set; } +} + +public interface IAuth0CibaService +{ + /// + /// Initiates a Client-Initiated Backchannel Authentication (CIBA) flow. + /// + /// Contains the information required for initiating the CIBA request. + Task InitiateAuthenticationAsync(CibaInitiationRequest request); + + /// + /// Polls the token endpoint to check the status of a CIBA request and retrieve tokens upon completion. + /// + /// The information required to poll for the CIBA status. + /// + /// Details about the CIBA completion status or the retrieved tokens. + Task PollForTokensAsync(CibaInitiationDetails cibaInitiationDetails, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/Auth0CibaServiceTest.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/Auth0CibaServiceTest.cs new file mode 100644 index 0000000..c25c095 --- /dev/null +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/Auth0CibaServiceTest.cs @@ -0,0 +1,192 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +using Moq; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +using Auth0.AspNetCore.Authentication.ClientInitiatedBackChannelAuthentication; +using Auth0.AuthenticationApi; +using Auth0.AuthenticationApi.Models.Ciba; +using Auth0.Core.Exceptions; + +namespace Auth0.AspNetCore.Authentication.IntegrationTests; + +public class Auth0CibaServiceTest +{ + private readonly IAuth0CibaService _auth0CibaService; + private readonly Mock _mockAuthenticationApiClient = new(); + + public Auth0CibaServiceTest() + { + _auth0CibaService = new Auth0CibaService(_mockAuthenticationApiClient.Object, Options.Create( + new Auth0WebAppOptions() + { + ClientId = "clientId", + ClientSecret = "secret" + }), new NullLogger()); + } + + [Fact] + public async Task InitiateAuthenticationAsync_ReturnsCibaInitiationDetails_OnSuccessfulRequest() + { + // Arrange + var request = new CibaInitiationRequest + { + Audience = "test-audience", + LoginHint = new LoginHint { Format = "test-format", Issuer = "test-issuer", Subject = "test-subject" }, + Scope = "openid", + RequestExpiry = 300, + AdditionalProperties = null, + BindingMessage = "test-binding-message" + }; + + var cibaResponse = new ClientInitiatedBackchannelAuthorizationResponse + { + AuthRequestId = "test-auth-request-id", + ExpiresIn = 300, + Interval = 5 + }; + + _mockAuthenticationApiClient + .Setup(client => + client.ClientInitiatedBackchannelAuthorization( + It.IsAny(), It.IsAny())) + .ReturnsAsync(cibaResponse); + + // Act + var result = await _auth0CibaService.InitiateAuthenticationAsync(request); + + // Assert + result.Should().NotBeNull(); + result.IsSuccessful.Should().BeTrue(); + cibaResponse.Interval.Should().Be(result.Interval); + cibaResponse.ExpiresIn.Should().Be(result.ExpiresIn); + cibaResponse.AuthRequestId.Should().Be(result.AuthRequestId); + } + + [Fact] + public async Task InitiateAuthenticationAsync_ThrowsException_OnApiError() + { + // Arrange + var request = new CibaInitiationRequest + { + Audience = "test-audience", + LoginHint = new LoginHint { Format = "test-format", Issuer = "test-issuer", Subject = "test-subject" }, + Scope = "openid" + }; + + _mockAuthenticationApiClient + .Setup(client => + client.ClientInitiatedBackchannelAuthorization( + It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("API error")); + + // Act & Assert + await Assert.ThrowsAsync(() => _auth0CibaService.InitiateAuthenticationAsync(request)); + } + + [Fact] + public async Task PollForTokensAsync_ReturnsCibaCompletionDetails_OnSuccessfulTokenRetrieval() + { + // Arrange + var initDetails = new CibaInitiationDetails + { + AuthRequestId = "test-auth-request-id", + Interval = 5 + }; + + var tokenResponse = new ClientInitiatedBackchannelAuthorizationTokenResponse + { + AccessToken = "test-access-token", + IdToken = "test-id-token", + TokenType = "Bearer", + Scope = "openid", + ExpiresIn = 3600, + RefreshToken = "test-refresh-token" + }; + + _mockAuthenticationApiClient + .Setup(client => client.GetTokenAsync(It.IsAny(), + It.IsAny())) + .ReturnsAsync(tokenResponse); + + // Act + var result = await _auth0CibaService.PollForTokensAsync(initDetails); + + // Assert + result.Should().NotBeNull(); + result.IsSuccessful.Should().BeTrue(); + tokenResponse.AccessToken.Should().BeEquivalentTo(result.AccessToken); + tokenResponse.IdToken.Should().BeEquivalentTo(result.IdToken); + tokenResponse.TokenType.Should().BeEquivalentTo(result.TokenType); + tokenResponse.Scope.Should().BeEquivalentTo(result.Scope); + tokenResponse.ExpiresIn.Should().Be(result.ExpiresIn); + tokenResponse.RefreshToken.Should().BeEquivalentTo(result.RefreshToken); + } + + [Fact] + public async Task PollForTokensAsync_ReturnsPendingStatus_OnAuthorizationPendingError() + { + // Arrange + var initDetails = new CibaInitiationDetails + { + AuthRequestId = "test-auth-request-id", + Interval = 1 + }; + + _mockAuthenticationApiClient + .SetupSequence(client => + client.GetTokenAsync(It.IsAny(), + It.IsAny())) + .ThrowsAsync(new ErrorApiException(HttpStatusCode.InternalServerError, new ApiError + { Error = "authorization_pending", Message = "Authorization is pending" })) + .ReturnsAsync(new ClientInitiatedBackchannelAuthorizationTokenResponse() + { + AccessToken = "test-access-token", + IdToken = "test-id-token", + TokenType = "Bearer", + Scope = "openid", + ExpiresIn = 3600 + }); + + // Act + var result = await _auth0CibaService.PollForTokensAsync(initDetails); + + // Assert + result.Should().NotBeNull(); + result.IsSuccessful.Should().BeTrue(); + result.AccessToken.Should().Be("test-access-token"); + } + + [Fact] + public async Task PollForTokensAsync_ReturnsErrorDetails_OnNonPendingError() + { + // Arrange + var initDetails = new CibaInitiationDetails + { + AuthRequestId = "test-auth-request-id", + Interval = 5 + }; + + var apiError = new ApiError { Error = "invalid_request", Message = "Invalid request" }; + + _mockAuthenticationApiClient + .Setup(client => client.GetTokenAsync(It.IsAny(), + It.IsAny())) + .ThrowsAsync(new ErrorApiException(HttpStatusCode.InternalServerError, apiError)); + + // Act + var result = await _auth0CibaService.PollForTokensAsync(initDetails); + + // Assert + result.Should().NotBeNull(); + result.IsSuccessful.Should().BeFalse(); + apiError.Error.Should().BeEquivalentTo(result.Error); + apiError.Message.Should().BeEquivalentTo(result.ErrorMessage); + } +} \ No newline at end of file