Merge branch 'feature/openid' into 'dev'

Resolve "OAuth"

See merge request leon.hoppe/hopframe!4
This commit was merged in pull request #42.
This commit is contained in:
2024-12-22 14:17:05 +00:00
31 changed files with 747 additions and 48 deletions

View File

@@ -1,10 +1,13 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAsyncValueTaskMethodBuilderT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1e8feaadf5c3fa14d36ea2a638c432a2e1a47b7837d8b83d88303c5d9c15cf_003FAsyncValueTaskMethodBuilderT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fca451c12d69fe026a0e7e9b1a0ddbf4cf6f6b8316cb2aec7984a7241813f648_003FAuthenticationHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8525b7a9e58c77f532f1a88d4f2897e3c2baf316b9eb2c391b242a3885fcce6_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEditContextDataAnnotationsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc307cd57fb42fc4c7fb9795381958122734d3750f41b6c1735c7d132ecda70_003FEditContextDataAnnotationsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHttpContextAccessor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe1b239a13ce466b829e79538c654ff54e938_003Fa5_003F4093f165_003FIHttpContextAccessor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb7208b3f72528d22781d25fde9a55271bdf2b5aade4f03b1324579a25493cd8_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenIdConnectConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2820ca73717f4b1ab2b3b74fd61961bd1d5b0_003F7e_003F8be6b109_003FOpenIdConnectConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARedirectResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2261f0cc7e883c3c89b8e6c7ea509ac7c52d0629e68690ed216e1cc0c8ac_003FRedirectResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2c8e7ca976f350cba9836d5565dac56b11e0b56656fa786460eb1395857a6fa_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationMessageStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffc81648e473bb3cc818f71427c286ecddc3604d2f4c69c565205bb89e8b4ef4_003FValidationMessageStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD;
@@ -71,6 +74,9 @@

View File

@@ -6,6 +6,8 @@ A simple backend management api for ASP.NET Core Web APIs
- [x] User authentication
- [x] Permission management
- [x] Generated frontend administration boards
- [x] API token support
- [x] OpenID authentication integration
# Usage
There are two different versions of HopFrame, either the Web API version or the full Blazor web version.

View File

@@ -24,7 +24,9 @@ by configuring your configuration to load these.
> custom configurations / HopFrame services.
You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types.
These get combined to a single time span.
These get combined to a single time span. You can also completely disable the default authentication
by setting the `DefaultAuthentication` to `false`. Note that you will no longer be able to login in any
way unless you enabled the [OpenID](./openid.md) authentication.
#### Configuration example
```json

120
docs/openid.md Normal file
View File

@@ -0,0 +1,120 @@
# OpenID Authentication
The HopFrame allows you to use an OpenID provider as your authentication provider for single sign on or better security
etc. To use it, you just simply need to configure it through the `appsettings.json` or environment variables.
>**Note**: The Blazor module has not yet implemented endpoints for the login process, but the middleware is correctly
> configured and the `IOpenIdAccessor` service is also provided for you to easily implement the endpoints yourself.
When you have enabled the integration, new endpoints will also be provided to perform the authentication.
simply use the swagger explorer to look up how the endpoints function. They're all under the subroute
`/api/v1/openid/`.
## Configure the HopFrame to use OpenID authentication
1. Create / Configure your OpenID provider:
- Save the ClientID and Client Secret from the provider, because you need it later.
- The default redirect uri looks something like this: `https://example.com/api/v1/openid/callback`.
- **Replace** the origin with the FQDN of your service.
- In order for the HopFrame to automatically renew expired access tokens you need to enable the `offline_access` scope.
- The integration also works without doing that, but then you need to reauthenticate every time your access token expires.
2. Configure the HopFrame integration:
>**Hint**: All of these configuration options can also be defined as environment variables. Use '__'
> to separate the namespaces like so: `HOPFRAME__AUTHENTICATION__OPENID__ENABLED=true`
- Add the following lines to your `appsettings.json`:
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"Enabled": true,
"Issuer": "your-issuer",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret"
}
}
}
```
>**Hint**: If you are using Authentik, the issuer url looks something like this: `https://auth.example.com/application/o/application-name/`.
> Just replace the FQDN and application-name with your configured application.
- **Optional**: You can also disable the default authentication via the config:
```json
"HopFrame": {
"Authentication": {
"DefaultAuthentication": false
}
}
```
- **Optional**: By default, the HopFrame will cache the api responses to reduce api latency. This can also be configured in the config (the cache can also be completely disabled here):
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"Cache": {
"Enabled": true,
"Configuration": {
"Hours": 5
},
"Auth": {
"Seconds": 90
},
"Inspection": {
"Minutes": 5
}
}
}
}
}
```
- **Optional**: You can also define your own callback endpoint like so (you also need to add / replace the endpoint in the provider settings):
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"Callback": "https://example.com/auth/callback"
}
}
}
```
- **Optional**: You can also prevent new users from being created by disabling it in the config:
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"GenerateUsers": false
}
}
}
```
## Use the abstraction to integrate OpenID yourself
The HopFrame has a service, that simplifies the communication with the OpenID provider called `IOpenIdAccessor`.
You can inject it like every other service in your application.
```csharp
public interface IOpenIdAccessor {
Task<OpenIdConfiguration> LoadConfiguration();
Task<OpenIdToken> RequestToken(string code, string defaultCallback);
Task<string> ConstructAuthUri(string defaultCallback, string state = null);
Task<OpenIdIntrospection> InspectToken(string token);
Task<OpenIdToken> RefreshAccessToken(string refreshToken);
}
```

View File

@@ -9,6 +9,7 @@ The HopFrame comes in two variations, you can eiter only use the backend with so
- [Base Models](./models.md)
- [Authentication](./authentication.md)
- [Permissions](./permissions.md)
- [OpenID Integration](./openid.md)
## HopFrame Web API

View File

@@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Controller;
[ApiController]
[Route("api/v1/authentication")]
public class SecurityController(IAuthLogic auth) : ControllerBase {
[Route("api/v1/auth")]
public class AuthController(IAuthLogic auth) : ControllerBase {
[HttpPut("login")]
public async Task<ActionResult<SingleValueResult<string>>> Login([FromBody] UserLogin login) {

View File

@@ -0,0 +1,84 @@
using HopFrame.Api.Models;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace HopFrame.Api.Controller;
[ApiController, Route("api/v1/openid")]
public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions> options) : ControllerBase {
public const string DefaultCallback = "api/v1/openid/callback";
[HttpGet("redirect")]
public async Task<IActionResult> RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) {
var uri = await accessor.ConstructAuthUri(DefaultCallback, redirectAfter);
if (performRedirect == 1) {
return Redirect(uri);
}
return Ok(new SingleValueResult<string>(uri));
}
[HttpGet("callback")]
public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state) {
if (string.IsNullOrEmpty(code)) {
return BadRequest("Authorization code is missing");
}
var token = await accessor.RequestToken(code, DefaultCallback);
if (token is null) {
return Forbid("Authorization code is not valid");
}
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
HttpOnly = false,
Secure = true
});
Response.Cookies.Append(ITokenContext.RefreshTokenType, token.RefreshToken, new CookieOptions {
MaxAge = options.Value.RefreshToken.ConstructTimeSpan,
HttpOnly = false,
Secure = true
});
if (string.IsNullOrEmpty(state)) {
return Ok(new SingleValueResult<string>(token.AccessToken));
}
return Redirect(state.Replace("{token}", token.AccessToken));
}
[HttpGet("refresh")]
public async Task<IActionResult> Refresh() {
var refreshToken = Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken))
return BadRequest("Refresh token not provided");
var token = await accessor.RefreshAccessToken(refreshToken);
if (token is null)
return NotFound("Refresh token not valid");
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
HttpOnly = false,
Secure = true
});
return Ok(new SingleValueResult<string>(token.AccessToken));
}
[HttpDelete("logout")]
public IActionResult Logout() {
Response.Cookies.Delete(ITokenContext.RefreshTokenType);
Response.Cookies.Delete(ITokenContext.AccessTokenType);
return Ok();
}
}

View File

@@ -19,8 +19,16 @@ public static class ServiceCollectionExtensions {
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddMvcCore().UseSpecificControllers(typeof(SecurityController));
var controllers = new List<Type>();
if (configuration.GetValue<bool>("HopFrame:Authentication:DefaultAuthentication"))
controllers.Add(typeof(AuthController));
if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled"))
controllers.Add(typeof(OpenIdController));
AddHopFrameNoEndpoints<TDbContext>(services, configuration);
services.AddMvcCore().UseSpecificControllers(controllers.ToArray());
}
/// <summary>
@@ -30,6 +38,11 @@ public static class ServiceCollectionExtensions {
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrameNoEndpoints<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddMvcCore().ConfigureApplicationPartManager(manager => {
var endpoints = manager.ApplicationParts.SingleOrDefault(p => p.Name == typeof(ServiceCollectionExtensions).Namespace!.Replace(".Extensions", ""));
manager.ApplicationParts.Remove(endpoints);
});
services.AddHopFrameRepositories<TDbContext>();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddScoped<IAuthLogic, AuthLogic>();

View File

@@ -12,6 +12,8 @@ namespace HopFrame.Api.Logic.Implementation;
internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions<HopFrameAuthenticationOptions> options) : IAuthLogic {
public async Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login) {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
var user = await users.GetUserByEmail(login.Email);
if (user is null)
@@ -38,6 +40,8 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
}
public async Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register) {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
if (register.Password.Length < 8)
return LogicResult<SingleValueResult<string>>.BadRequest("Password needs to be at least 8 characters long");
@@ -69,6 +73,8 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
}
public async Task<LogicResult<SingleValueResult<string>>> Authenticate() {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken))
@@ -101,8 +107,6 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return LogicResult.Conflict("access or refresh token not provided");
await tokens.DeleteUserTokens(tokenContext.User);
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType);

View File

@@ -8,6 +8,7 @@ public class Token : IPermissionOwner {
public const int RefreshTokenType = 0;
public const int AccessTokenType = 1;
public const int ApiTokenType = 2;
public const int OpenIdTokenType = 3;
/// <summary>
/// Defines the Type of the stored Token

View File

@@ -1,5 +1,6 @@
using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace HopFrame.Database.Repositories.Implementation;

View File

@@ -2,6 +2,8 @@ using System.Security.Claims;
using System.Text.Encodings.Web;
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
@@ -19,7 +21,10 @@ public class HopFrameAuthentication(
ISystemClock clock,
ITokenRepository tokens,
IPermissionRepository perms,
IOptions<HopFrameAuthenticationOptions> tokenOptions)
IOptions<HopFrameAuthenticationOptions> tokenOptions,
IOptions<OpenIdOptions> openIdOptions,
IUserRepository users,
IOpenIdAccessor accessor)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock) {
public const string SchemeName = "HopFrame.Authentication";
@@ -33,6 +38,40 @@ public class HopFrameAuthentication(
var tokenEntry = await tokens.GetToken(accessToken);
if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled && !Guid.TryParse(accessToken, out _)) {
var result = await accessor.InspectToken(accessToken);
if (result is null || !result.Active)
return AuthenticateResult.Fail("Invalid OpenID Connect token");
var email = result.Email;
if (string.IsNullOrEmpty(email))
return AuthenticateResult.Fail("OpenID user has no email associated to it");
var user = await users.GetUserByEmail(email);
if (user is null) {
if (!openIdOptions.Value.GenerateUsers)
return AuthenticateResult.Fail("OpenID user does not exist");
var username = result.PreferredUsername;
user = await users.AddUser(new User {
Email = email,
Username = username
});
}
var token = new Token {
Owner = user,
CreatedAt = DateTime.Now,
Type = Token.OpenIdTokenType
};
var identity = await GenerateClaims(token, perms);
return AuthenticateResult.Success(new AuthenticationTicket(identity, Scheme.Name));
}
if (!tokenOptions.Value.DefaultAuthentication)
return AuthenticateResult.Fail("HopFrame authentication scheme is disabled");
if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist");
if (tokenEntry.Type == Token.ApiTokenType) {
@@ -42,17 +81,22 @@ public class HopFrameAuthentication(
if (tokenEntry.Owner is null)
return AuthenticateResult.Fail("The provided Access Token does not match any user");
var principal = await GenerateClaims(tokenEntry, perms);
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
public static async Task<ClaimsPrincipal> GenerateClaims(Token token, IPermissionRepository perms) {
var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, accessToken),
new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString())
new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
};
var permissions = await perms.GetFullPermissions(tokenEntry);
var permissions = await perms.GetFullPermissions(token);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
var principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(claims, SchemeName));
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
return principal;
}
}

View File

@@ -1,3 +1,6 @@
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Implementation;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
using HopFrame.Security.Options;
@@ -20,8 +23,13 @@ public static class HopFrameAuthenticationExtensions {
service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
service.AddScoped<ITokenContext, TokenContextImplementor>();
service.AddHttpClient();
service.AddMemoryCache();
service.AddScoped<IOpenIdAccessor, OpenIdAccessor>();
service.AddOptionsFromConfiguration<HopFrameAuthenticationOptions>(configuration);
service.AddOptionsFromConfiguration<AdminPermissionOptions>(configuration);
service.AddOptionsFromConfiguration<OpenIdOptions>(configuration);
service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication>(HopFrameAuthentication.SchemeName, _ => {});
service.AddAuthorization();

View File

@@ -5,8 +5,10 @@ namespace HopFrame.Security.Authentication;
public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
public override string Position { get; } = "HopFrame:Authentication";
public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : new(AccessToken.Days, AccessToken.Hours, AccessToken.Minutes, AccessToken.Seconds);
public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : new(RefreshToken.Days, RefreshToken.Hours, RefreshToken.Minutes, RefreshToken.Seconds);
public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : AccessToken.ConstructTimeSpan;
public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : RefreshToken.ConstructTimeSpan;
public bool DefaultAuthentication { get; set; } = true;
public TokenTime AccessToken { get; set; }
public TokenTime RefreshToken { get; set; }
@@ -16,5 +18,7 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
public int Hours { get; set; }
public int Minutes { get; set; }
public int Seconds { get; set; }
public TimeSpan ConstructTimeSpan => new(Days, Hours, Minutes, Seconds);
}
}

View File

@@ -0,0 +1,11 @@
using HopFrame.Security.Authentication.OpenID.Models;
namespace HopFrame.Security.Authentication.OpenID;
public interface IOpenIdAccessor {
Task<OpenIdConfiguration> LoadConfiguration();
Task<OpenIdToken> RequestToken(string code, string defaultCallback);
Task<string> ConstructAuthUri(string defaultCallback, string state = null);
Task<OpenIdIntrospection> InspectToken(string token);
Task<OpenIdToken> RefreshAccessToken(string refreshToken);
}

View File

@@ -0,0 +1,123 @@
using System.Text.Json;
using HopFrame.Security.Authentication.OpenID.Models;
using HopFrame.Security.Authentication.OpenID.Options;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace HopFrame.Security.Authentication.OpenID.Implementation;
internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdOptions> options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor {
private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration";
private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:";
private const string TokenCacheKey = "HopFrame:OpenID:Token:";
public async Task<OpenIdConfiguration> LoadConfiguration() {
if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled && cache.TryGetValue(ConfigurationCacheKey, out object cachedConfiguration)) {
return cachedConfiguration as OpenIdConfiguration;
}
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration"));
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var config = await JsonSerializer.DeserializeAsync<OpenIdConfiguration>(await response.Content.ReadAsStreamAsync());
if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled)
cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan);
return config;
}
public async Task<OpenIdToken> RequestToken(string code, string defaultCallback) {
if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled && cache.TryGetValue(AuthCodeCacheKey + code, out object cachedToken)) {
return cachedToken as OpenIdToken;
}
var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}";
var configuration = await LoadConfiguration();
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", callback },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var token = await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled)
cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan);
return token;
}
public async Task<string> ConstructAuthUri(string defaultCallback, string state = null) {
var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}";
var configuration = await LoadConfiguration();
return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={callback}&scope=openid%20profile%20email%20offline_access&state={state}";
}
public async Task<OpenIdIntrospection> InspectToken(string token) {
if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled && cache.TryGetValue(TokenCacheKey + token, out object cachedToken)) {
return cachedToken as OpenIdIntrospection;
}
var configuration = await LoadConfiguration();
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.IntrospectionEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "token", token },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var introspection = await JsonSerializer.DeserializeAsync<OpenIdIntrospection>(await response.Content.ReadAsStreamAsync());
if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled)
cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan);
return introspection;
}
public async Task<OpenIdToken> RefreshAccessToken(string refreshToken) {
var configuration = await LoadConfiguration();
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "grant_type", "refresh_token" },
{ "refresh_token", refreshToken },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
return await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
}
}

View File

@@ -0,0 +1,68 @@
using System.Text.Json.Serialization;
namespace HopFrame.Security.Authentication.OpenID.Models;
public sealed class OpenIdConfiguration {
[JsonPropertyName("issuer")]
public string Issuer { get; set; }
[JsonPropertyName("authorization_endpoint")]
public string AuthorizationEndpoint { get; set; }
[JsonPropertyName("token_endpoint")]
public string TokenEndpoint { get; set; }
[JsonPropertyName("userinfo_endpoint")]
public string UserinfoEndpoint { get; set; }
[JsonPropertyName("end_session_endpoint")]
public string EndSessionEndpoint { get; set; }
[JsonPropertyName("introspection_endpoint")]
public string IntrospectionEndpoint { get; set; }
[JsonPropertyName("revocation_endpoint")]
public string RevocationEndpoint { get; set; }
[JsonPropertyName("device_authorization_endpoint")]
public string DeviceAuthorizationEndpoint { get; set; }
[JsonPropertyName("response_types_supported")]
public List<string> ResponseTypesSupported { get; set; }
[JsonPropertyName("response_modes_supported")]
public List<string> ResponseModesSupported { get; set; }
[JsonPropertyName("jwks_uri")]
public string JwksUri { get; set; }
[JsonPropertyName("grant_types_supported")]
public List<string> GrantTypesSupported { get; set; }
[JsonPropertyName("id_token_signing_alg_values_supported")]
public List<string> IdTokenSigningAlgValuesSupported { get; set; }
[JsonPropertyName("subject_types_supported")]
public List<string> SubjectTypesSupported { get; set; }
[JsonPropertyName("token_endpoint_auth_methods_supported")]
public List<string> TokenEndpointAuthMethodsSupported { get; set; }
[JsonPropertyName("acr_values_supported")]
public List<string> AcrValuesSupported { get; set; }
[JsonPropertyName("scopes_supported")]
public List<string> ScopesSupported { get; set; }
[JsonPropertyName("request_parameter_supported")]
public bool RequestParameterSupported { get; set; }
[JsonPropertyName("claims_supported")]
public List<string> ClaimsSupported { get; set; }
[JsonPropertyName("claims_parameter_supported")]
public bool ClaimsParameterSupported { get; set; }
[JsonPropertyName("code_challenge_methods_supported")]
public List<string> CodeChallengeMethodsSupported { get; set; }
}

View File

@@ -0,0 +1,62 @@
using System.Text.Json.Serialization;
namespace HopFrame.Security.Authentication.OpenID.Models;
public sealed class OpenIdIntrospection {
[JsonPropertyName("iss")]
public string Issuer { get; set; }
[JsonPropertyName("sub")]
public string Subject { get; set; }
[JsonPropertyName("aud")]
public string Audience { get; set; }
[JsonPropertyName("exp")]
public long Expiration { get; set; }
[JsonPropertyName("iat")]
public long IssuedAt { get; set; }
[JsonPropertyName("auth_time")]
public long AuthTime { get; set; }
[JsonPropertyName("acr")]
public string Acr { get; set; }
[JsonPropertyName("amr")]
public List<string> AuthenticationMethods { get; set; }
[JsonPropertyName("sid")]
public string SessionId { get; set; }
[JsonPropertyName("email")]
public string Email { get; set; }
[JsonPropertyName("email_verified")]
public bool EmailVerified { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("given_name")]
public string GivenName { get; set; }
[JsonPropertyName("preferred_username")]
public string PreferredUsername { get; set; }
[JsonPropertyName("nickname")]
public string Nickname { get; set; }
[JsonPropertyName("groups")]
public List<string> Groups { get; set; }
[JsonPropertyName("active")]
public bool Active { get; set; }
[JsonPropertyName("scope")]
public string Scope { get; set; }
[JsonPropertyName("client_id")]
public string ClientId { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace HopFrame.Security.Authentication.OpenID.Models;
public sealed class OpenIdToken {
[JsonPropertyName("access_token")]
public string AccessToken { get; set; }
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }
[JsonPropertyName("token_type")]
public string TokenType { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("id_token")]
public string IdToken { get; set; }
}

View File

@@ -0,0 +1,53 @@
using HopFrame.Security.Options;
namespace HopFrame.Security.Authentication.OpenID.Options;
public sealed class OpenIdOptions : OptionsFromConfiguration {
public override string Position { get; } = "HopFrame:Authentication:OpenID";
public bool Enabled { get; set; } = false;
public bool GenerateUsers { get; set; } = true;
public string Issuer { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string Callback { get; set; }
public HopFrameAuthenticationOptions.TokenTime RefreshToken { get; set; } = new() {
Days = 30
};
public CachingOptions Cache { get; set; } = new() {
Enabled = true,
Configuration = new() {
Enabled = true,
TTL = new() {
Minutes = 10
}
},
Auth = new() {
Enabled = true,
TTL = new() {
Seconds = 30
}
},
Inspection = new() {
Enabled = true,
TTL = new() {
Minutes = 2
}
}
};
public class CachingTypeOptions {
public bool Enabled { get; set; }
public HopFrameAuthenticationOptions.TokenTime TTL { get; set; }
}
public class CachingOptions {
public bool Enabled { get; set; }
public CachingTypeOptions Configuration { get; set; }
public CachingTypeOptions Auth { get; set; }
public CachingTypeOptions Inspection { get; set; }
}
}

View File

@@ -21,6 +21,4 @@ public interface ITokenContext {
/// The access token the user provided
/// </summary>
Token AccessToken { get; }
IList<string> ContextualPermissions { get; }
}

View File

@@ -1,15 +1,19 @@
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication.OpenID.Options;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace HopFrame.Security.Claims;
internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IPermissionRepository permissions) : ITokenContext {
internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IOptions<OpenIdOptions> options) : ITokenContext {
public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId());
public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult();
public Token AccessToken => tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult();
public IList<string> ContextualPermissions => permissions.GetFullPermissions(AccessToken).GetAwaiter().GetResult();
public Token AccessToken => options.Value.Enabled ? new Token {
Owner = User,
Type = Token.OpenIdTokenType,
CreatedAt = DateTime.Now
} : tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult();
}

View File

@@ -1,7 +1,6 @@
using System.Security.Claims;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Web.Services;
using Microsoft.AspNetCore.Http;
@@ -21,15 +20,9 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm
return;
}
var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
};
var permissions = await perms.GetFullPermissions(token);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName));
var principal = await HopFrameAuthentication.GenerateClaims(token, perms);
if (principal?.Identity is ClaimsIdentity identity)
context.User.AddIdentity(identity);
}
await next?.Invoke(context);

View File

@@ -1,6 +1,8 @@
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using Microsoft.AspNetCore.Http;
@@ -13,10 +15,15 @@ internal class AuthService(
IHttpContextAccessor httpAccessor,
ITokenRepository tokens,
ITokenContext context,
IOptions<HopFrameAuthenticationOptions> options)
IOptions<HopFrameAuthenticationOptions> options,
IOptions<OpenIdOptions> openIdOptions,
IOpenIdAccessor accessor,
IUserRepository users)
: IAuthService {
public async Task Register(UserRegister register) {
if (!options.Value.DefaultAuthentication) return;
var user = await userService.AddUser(new User {
Username = register.Username,
Email = register.Email,
@@ -41,6 +48,8 @@ internal class AuthService(
}
public async Task<bool> Login(UserLogin login) {
if (!options.Value.DefaultAuthentication) return false;
var user = await userService.GetUserByEmail(login.Email);
if (user == null) return false;
@@ -75,6 +84,45 @@ internal class AuthService(
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
if (openIdOptions.Value.Enabled && !Guid.TryParse(refreshToken, out _)) {
var openIdToken = await accessor.RefreshAccessToken(refreshToken);
if (openIdToken is null)
return null;
var inspection = await accessor.InspectToken(openIdToken.AccessToken);
var email = inspection.Email;
if (string.IsNullOrEmpty(email))
return null;
var user = await users.GetUserByEmail(email);
if (user is null) {
if (!openIdOptions.Value.GenerateUsers)
return null;
var username = inspection.PreferredUsername;
user = await users.AddUser(new User {
Email = email,
Username = username
});
}
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, openIdToken.AccessToken, new CookieOptions {
MaxAge = TimeSpan.FromSeconds(openIdToken.ExpiresIn),
HttpOnly = false,
Secure = true
});
return new() {
Owner = user,
CreatedAt = DateTime.Now,
Type = Token.OpenIdTokenType
};
}
if (!options.Value.DefaultAuthentication)
return null;
var token = await tokens.GetToken(refreshToken);
if (token is null || token.Type != Token.RefreshTokenType) return null;
@@ -96,7 +144,7 @@ internal class AuthService(
var accessToken = context.AccessToken;
if (accessToken is null) return false;
if (accessToken.Type != Token.AccessTokenType) return false;
if (accessToken.Type != Token.AccessTokenType && accessToken.Type != Token.OpenIdTokenType) return false;
if (accessToken.CreatedAt + options.Value.AccessTokenTime < DateTime.Now) return false;
if (accessToken.Owner is null) return false;

View File

@@ -15,8 +15,8 @@ namespace HopFrame.Testing.Api.Controllers;
public class TestController(ITokenContext userContext, DatabaseContext context, ITokenRepository tokens, IPermissionRepository permissions) : ControllerBase {
[HttpGet("permissions"), Authorized]
public ActionResult<IList<string>> Permissions() {
return new ActionResult<IList<string>>(userContext.ContextualPermissions);
public async Task<ActionResult<IList<string>>> Permissions() {
return new ActionResult<IList<string>>(await permissions.GetFullPermissions(userContext.AccessToken));
}
[HttpGet("generate")]
@@ -67,4 +67,10 @@ public class TestController(ITokenContext userContext, DatabaseContext context,
await tokens.DeleteToken(token);
}
[HttpGet("url")]
public async Task<ActionResult<SingleValueResult<string>>> GetUrl() {
var protocol = Request.IsHttps ? "https" : "http";
return Ok($"{protocol}://{Request.Host.Value}/auth/callback");
}
}

View File

@@ -6,6 +6,7 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddHttpClient();
builder.Services.AddControllers();
builder.Services.AddHopFrame<DatabaseContext>(builder.Configuration);
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle

View File

@@ -329,7 +329,7 @@ public class AuthLogicTests {
}
[Fact]
public async Task Logout_With_NoAccessToken_Should_Fail() {
public async Task Logout_With_NoAccessToken_Should_Succeed() {
// Arrange
var (auth, context) = SetupEnvironment(provideAccessToken: false);
context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
@@ -339,14 +339,13 @@ public class AuthLogicTests {
var result = await auth.Logout();
// Assert
Assert.False(result.IsSuccessful);
Assert.Equal(HttpStatusCode.Conflict, result.State);
Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
Assert.True(result.IsSuccessful);
Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
}
[Fact]
public async Task Logout_With_NoRefreshToken_Should_Fail() {
public async Task Logout_With_NoRefreshToken_Should_Succeed() {
// Arrange
var (auth, context) = SetupEnvironment();
context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
@@ -356,10 +355,9 @@ public class AuthLogicTests {
var result = await auth.Logout();
// Assert
Assert.False(result.IsSuccessful);
Assert.Equal(HttpStatusCode.Conflict, result.State);
Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
Assert.True(result.IsSuccessful);
Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
}
[Fact]

View File

@@ -2,6 +2,8 @@ using System.Text.Encodings.Web;
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
@@ -46,7 +48,17 @@ public class AuthenticationTests {
.Setup(x => x.GetFullPermissions(It.IsAny<Token>()))
.ReturnsAsync(new List<string>());
var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions()));
var auth = new HopFrameAuthentication(
options.Object,
logger.Object,
encoder.Object,
clock.Object,
tokens.Object,
perms.Object,
new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions()),
new OptionsWrapper<OpenIdOptions>(new OpenIdOptions()),
new Mock<IUserRepository>().Object,
new Mock<IOpenIdAccessor>().Object);
var context = new DefaultHttpContext();
if (provideCorrectToken)
context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString());

View File

@@ -1,6 +1,8 @@
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using HopFrame.Tests.Web.Extensions;
@@ -68,7 +70,16 @@ public class AuthServiceTests {
.Setup(c => c.AccessToken)
.Returns(providedAccessToken);
return (new AuthService(users.Object, accessor, tokens.Object, context.Object, new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions())), accessor.HttpContext);
return (new AuthService(
users.Object,
accessor,
tokens.Object,
context.Object,
new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions()),
new OptionsWrapper<OpenIdOptions>(new OpenIdOptions()),
new Mock<IOpenIdAccessor>().Object,
users.Object
), accessor.HttpContext);
}
private User CreateDummyUser() => new() {

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using Bunit;
using Bunit.TestDoubles;
using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Claims;
using HopFrame.Web.Components;
using Microsoft.AspNetCore.Components;