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);
+ }
+}