From 166134c6d871c291ac23700823d5c601f21f3ca6 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Tue, 24 Dec 2024 11:58:35 +0100 Subject: [PATCH] Added OpenID endpoints tests and documentation --- HopFrame.sln.DotSettings.user | 12 ++ docs/api/endpoints/auth.md | 120 ++++++++++++ docs/api/endpoints/openId.md | 82 ++++++++ docs/api/models.md | 63 +++++++ docs/models.md | 14 ++ .../Controller/OpenIdController.cs | 2 +- .../Controllers/OpenIdControllerTests.cs | 177 ++++++++++++++++++ 7 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 docs/api/endpoints/auth.md create mode 100644 docs/api/endpoints/openId.md create mode 100644 tests/HopFrame.Tests.Api/Controllers/OpenIdControllerTests.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index d71bdae..34c8e0d 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -14,6 +14,18 @@ <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /> <Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /> </AssemblyExplorer> + <SessionState ContinuousTestingMode="0" Name="Refresh_ShouldReturnConflict_WhenRefreshTokenNotValid" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::25DE1510-47E5-46FF-89A4-B9F99542218E::net8.0::HopFrame.Tests.Api.Controllers.OpenIdControllerTests.Refresh_ShouldReturnConflict_WhenRefreshTokenNotValid</TestId> + </TestAncestor> +</SessionState> + + + + + + + diff --git a/docs/api/endpoints/auth.md b/docs/api/endpoints/auth.md new file mode 100644 index 0000000..300c7ac --- /dev/null +++ b/docs/api/endpoints/auth.md @@ -0,0 +1,120 @@ +# Auth Endpoints + +## Used Models +- [UserLogin](../../models.md#userlogin) +- [UserRegister](../../models.md#userregister) +- [SingleValueResult](../../models.md#singlevalueresult) +- [UserPasswordValidation](../../models.md#userpasswordvalidation) + +## API Endpoint: Login + +**Endpoint:** `PUT /api/v1/auth/login` + +**Description:** Authenticates a user and provides access and refresh tokens. + +**Authorization Required:** No + +**Parameters:** +- **UserLogin** (required): The login credentials of the user. + ```json + { + "email": "string", + "password": "string" + } + ``` + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** HopFrame authentication scheme is disabled. +- **404 Not Found:** The provided email address was not found. +- **403 Forbidden:** The provided password is not correct. + +## API Endpoint: Register + +**Endpoint:** `POST /api/v1/auth/register` + +**Description:** Registers a new user and provides access and refresh tokens. + +**Authorization Required:** No + +**Parameters:** +- **UserRegister** (required): The registration details of the user. + ```json + { + "username": "string", + "email": "string", + "password": "string" + } + ``` + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** HopFrame authentication scheme is disabled or the password is too short. +- **409 Conflict:** Username or email is already registered. + +## API Endpoint: Authenticate + +**Endpoint:** `GET /api/v1/auth/authenticate` + +**Description:** Authenticates the user using the refresh token and provides a new access token. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** HopFrame authentication scheme is disabled or refresh token not provided. +- **404 Not Found:** The refresh token is not valid. +- **403 Forbidden:** The refresh token is expired. +- **409 Conflict:** The provided token is not a refresh token. + +## API Endpoint: Logout + +**Endpoint:** `DELETE /api/v1/auth/logout` + +**Description:** Logs out the user and deletes the access and refresh tokens. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** User is logged out successfully. + +## API Endpoint: Delete + +**Endpoint:** `DELETE /api/v1/auth/delete` + +**Description:** Deletes the user account. + +**Authorization Required:** Yes + +**Parameters:** +- **UserPasswordValidation** (required): The password validation for the user. + ```json + { + "password": "string" + } + ``` + +**Response:** +- **200 OK:** User account is deleted successfully. +- **403 Forbidden:** The provided password is not correct. diff --git a/docs/api/endpoints/openId.md b/docs/api/endpoints/openId.md new file mode 100644 index 0000000..185ed45 --- /dev/null +++ b/docs/api/endpoints/openId.md @@ -0,0 +1,82 @@ +# OpenID Endpoints + +## Used Models +- [SingleValueResult](../../models.md#singlevalueresult) + +## API Endpoint: RedirectToProvider + +**Endpoint:** `GET /api/v1/openid/redirect` + +**Description:** Redirects the user to the OpenID provider's authorization endpoint. + +**Authorization Required:** No + +**Parameters:** +- **redirectAfter** (query, optional): The URL to redirect to after authentication. +- **performRedirect** (query, optional): A flag to indicate if the user should be redirected (default is 1). + +**Response:** +- **302 Found:** Redirects the user to the OpenID provider's authorization endpoint. +- **200 OK:** Returns the constructed authorization URI. + ```json + { + "value": "string" + } + ``` + +## API Endpoint: Callback + +**Endpoint:** `GET /api/v1/openid/callback` + +**Description:** Handles the callback from the OpenID provider and exchanges the authorization code for tokens. + +**Authorization Required:** No + +**Parameters:** +- **code** (query, required): The authorization code received from the OpenID provider. +- **state** (query, optional): The state parameter to handle the redirect after authentication. + +**Response:** +- **200 OK:** Returns the access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** Authorization code is missing. +- **403 Forbidden:** Authorization code is not valid. + +## API Endpoint: Refresh + +**Endpoint:** `GET /api/v1/openid/refresh` + +**Description:** Refreshes the access token using the refresh token. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** Returns the refreshed access token. + ```json + { + "value": "string" + } + ``` +- **400 Bad Request:** Refresh token not provided. +- **409 Conflict**: Refresh token not valid. + +## API Endpoint: Logout + +**Endpoint:** `DELETE /api/v1/openid/logout` + +**Description:** Logs out the user by deleting the authentication cookies. + +**Authorization Required:** Yes + +**Parameters:** +- None + +**Response:** +- **200 OK:** User is logged out successfully. diff --git a/docs/api/models.md b/docs/api/models.md index c102452..c3ca02c 100644 --- a/docs/api/models.md +++ b/docs/api/models.md @@ -14,3 +14,66 @@ public sealed class UserPasswordValidation { public string Password { get; set; } } ``` + +## OpenIdConfiguration +```csharp +public sealed class OpenIdConfiguration { + public string Issuer { get; set; } + public string AuthorizationEndpoint { get; set; } + public string TokenEndpoint { get; set; } + public string UserinfoEndpoint { get; set; } + public string EndSessionEndpoint { get; set; } + public string IntrospectionEndpoint { get; set; } + public string RevocationEndpoint { get; set; } + public string DeviceAuthorizationEndpoint { get; set; } + public List ResponseTypesSupported { get; set; } + public List ResponseModesSupported { get; set; } + public string JwksUri { get; set; } + public List GrantTypesSupported { get; set; } + public List IdTokenSigningAlgValuesSupported { get; set; } + public List SubjectTypesSupported { get; set; } + public List TokenEndpointAuthMethodsSupported { get; set; } + public List AcrValuesSupported { get; set; } + public List ScopesSupported { get; set; } + public bool RequestParameterSupported { get; set; } + public List ClaimsSupported { get; set; } + public bool ClaimsParameterSupported { get; set; } + public List CodeChallengeMethodsSupported { get; set; } +} +``` + +## OpenIdIntrospection +```csharp +public sealed class OpenIdIntrospection { + public string Issuer { get; set; } + public string Subject { get; set; } + public string Audience { get; set; } + public long Expiration { get; set; } + public long IssuedAt { get; set; } + public long AuthTime { get; set; } + public string Acr { get; set; } + public List AuthenticationMethods { get; set; } + public string SessionId { get; set; } + public string Email { get; set; } + public bool EmailVerified { get; set; } + public string Name { get; set; } + public string GivenName { get; set; } + public string PreferredUsername { get; set; } + public string Nickname { get; set; } + public List Groups { get; set; } + public bool Active { get; set; } + public string Scope { get; set; } + public string ClientId { get; set; } +} +``` + +## OpenIdToken +```csharp +public sealed class OpenIdToken { + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string TokenType { get; set; } + public int ExpiresIn { get; set; } + public string IdToken { get; set; } +} +``` diff --git a/docs/models.md b/docs/models.md index 91fefd8..2cf8071 100644 --- a/docs/models.md +++ b/docs/models.md @@ -81,3 +81,17 @@ public class UserCreator { ```csharp public interface IPermissionOwner; ``` + +## SingleValueResult +```csharp +public struct SingleValueResult(TValue value) { + public TValue Value { get; set; } = value; +} +``` + +## UserPasswordValidation +```csharp +public sealed class UserPasswordValidation { + public string Password { get; set; } +} +``` diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs index 733821d..6d57813 100644 --- a/src/HopFrame.Api/Controller/OpenIdController.cs +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -51,7 +51,7 @@ public class OpenIdController(IOpenIdAccessor accessor) : ControllerBase { var token = await accessor.RefreshAccessToken(refreshToken); if (token is null) - return NotFound("Refresh token not valid"); + return Conflict("Refresh token not valid"); accessor.SetAuthenticationCookies(token); diff --git a/tests/HopFrame.Tests.Api/Controllers/OpenIdControllerTests.cs b/tests/HopFrame.Tests.Api/Controllers/OpenIdControllerTests.cs new file mode 100644 index 0000000..995506f --- /dev/null +++ b/tests/HopFrame.Tests.Api/Controllers/OpenIdControllerTests.cs @@ -0,0 +1,177 @@ +using HopFrame.Api.Controller; +using HopFrame.Api.Models; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Models; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace HopFrame.Tests.Api.Controllers; + +public class OpenIdControllerTests { + private (Mock, OpenIdController) SetupEnvironment(out HttpContext httpContext) { + var mockAccessor = new Mock(); + var controller = new OpenIdController(mockAccessor.Object); + + httpContext = new DefaultHttpContext(); + controller.ControllerContext = new ControllerContext { + HttpContext = httpContext + }; + return (mockAccessor, controller); + } + + [Fact] + public async Task RedirectToProvider_ShouldRedirect_WhenPerformRedirectIsTrue() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var uri = "https://example.com/auth"; + mockAccessor.Setup(a => a.ConstructAuthUri(It.IsAny())).ReturnsAsync(uri); + + // Act + var result = await controller.RedirectToProvider("https://redirectafter.com", 1); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal(uri, redirectResult.Url); + } + + [Fact] + public async Task RedirectToProvider_ShouldReturnOk_WhenPerformRedirectIsFalse() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var uri = "https://example.com/auth"; + mockAccessor.Setup(a => a.ConstructAuthUri(It.IsAny())).ReturnsAsync(uri); + + // Act + var result = await controller.RedirectToProvider("https://redirectafter.com", 0); + + // Assert + var okResult = Assert.IsType(result); + var singleValueResult = Assert.IsType>(okResult.Value); + Assert.Equal(uri, singleValueResult.Value); + } + + [Fact] + public async Task Callback_ShouldReturnBadRequest_WhenAuthorizationCodeIsMissing() { + // Arrange + var (_, controller) = SetupEnvironment(out _); + + // Act + var result = await controller.Callback(string.Empty, "state"); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal("Authorization code is missing", badRequestResult.Value); + } + + [Fact] + public async Task Callback_ShouldReturnForbidden_WhenAuthorizationCodeIsNotValid() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + mockAccessor.Setup(a => a.RequestToken(It.IsAny())).ReturnsAsync((OpenIdToken)null); + + // Act + var result = await controller.Callback("invalid_code", "state"); + + // Assert + var forbidResult = Assert.IsType(result); + Assert.Equal("Authorization code is not valid", forbidResult.AuthenticationSchemes.First()); + } + + [Fact] + public async Task Callback_ShouldReturnOk_WhenStateIsNull() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var token = new OpenIdToken { AccessToken = "valid_token" }; + mockAccessor.Setup(a => a.RequestToken(It.IsAny())).ReturnsAsync(token); + + // Act + var result = await controller.Callback("valid_code", null); + + // Assert + var okResult = Assert.IsType(result); + var singleValueResult = Assert.IsType>(okResult.Value); + Assert.Equal("valid_token", singleValueResult.Value); + } + + [Fact] + public async Task Callback_ShouldRedirect_WhenStateIsProvided() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out _); + var token = new OpenIdToken { AccessToken = "valid_token" }; + mockAccessor.Setup(a => a.RequestToken(It.IsAny())).ReturnsAsync(token); + + // Act + var result = await controller.Callback("valid_code", "https://redirect.com/{token}"); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal("https://redirect.com/valid_token", redirectResult.Url); + } + + [Fact] + public async Task Refresh_ShouldReturnBadRequest_WhenRefreshTokenNotProvided() { + // Arrange + var (_, controller) = SetupEnvironment(out _); + + // Act + var result = await controller.Refresh(); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal("Refresh token not provided", badRequestResult.Value); + } + + [Fact] + public async Task Refresh_ShouldReturnConflict_WhenRefreshTokenNotValid() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out var httpContext); + var cookies = new Mock(); + cookies + .SetupGet(c => c[ITokenContext.RefreshTokenType]) + .Returns("invalid_token"); + httpContext.Request.Cookies = cookies.Object; + mockAccessor.Setup(a => a.RefreshAccessToken(It.IsAny())).ReturnsAsync((OpenIdToken)null); + + // Act + var result = await controller.Refresh(); + + // Assert + var conflictResult = Assert.IsType(result); + Assert.Equal("Refresh token not valid", conflictResult.Value); + } + + [Fact] + public async Task Refresh_ShouldReturnOk_WhenRefreshTokenIsValid() { + // Arrange + var (mockAccessor, controller) = SetupEnvironment(out var httpContext); + var cookies = new Mock(); + cookies + .SetupGet(c => c[ITokenContext.RefreshTokenType]) + .Returns("valid_token"); + httpContext.Request.Cookies = cookies.Object; + var token = new OpenIdToken { AccessToken = "new_access_token", RefreshToken = "new_refresh_token", ExpiresIn = 3600 }; + mockAccessor.Setup(a => a.RefreshAccessToken(It.IsAny())).ReturnsAsync(token); + + // Act + var result = await controller.Refresh(); + + // Assert + var okResult = Assert.IsType(result); + var singleValueResult = Assert.IsType>(okResult.Value); + Assert.Equal("new_access_token", singleValueResult.Value); + } + + [Fact] + public void Logout_ShouldReturnOk() { + // Arrange + var (_, controller) = SetupEnvironment(out _); + + // Act + var result = controller.Logout(); + + // Assert + Assert.IsType(result); + } +}