From ba7584c771604d3b5fbf77dee9f801921c031bae Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 22:35:04 +0100 Subject: [PATCH 1/4] Added OpenID authentication method --- HopFrame.sln.DotSettings.user | 1 + src/HopFrame.Database/Models/Token.cs | 1 + .../Implementation/TokenRepository.cs | 1 + .../Authentication/HopFrameAuthentication.cs | 53 +++++++++++++-- .../HopFrameAuthenticationExtensions.cs | 7 ++ .../Authentication/OpenID/IOpenIdAccessor.cs | 10 +++ .../OpenID/Implementation/OpenIdAccessor.cs | 64 +++++++++++++++++ .../OpenID/Models/OpenIdConfiguration.cs | 68 +++++++++++++++++++ .../OpenID/Models/OpenIdIntrospection.cs | 62 +++++++++++++++++ .../OpenID/Models/OpenIdToken.cs | 17 +++++ .../OpenID/Options/OpenIdOptions.cs | 15 ++++ src/HopFrame.Security/Claims/ITokenContext.cs | 2 - .../Claims/TokenContextImplementor.cs | 12 ++-- .../Controllers/AuthController.cs | 28 ++++++++ .../Controllers/TestController.cs | 4 +- testing/HopFrame.Testing.Api/Program.cs | 1 + .../AuthenticationTests.cs | 14 +++- 17 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs create mode 100644 testing/HopFrame.Testing.Api/Controllers/AuthController.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 0d4f6c6..88abaf3 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -5,6 +5,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded <AssemblyExplorer> diff --git a/src/HopFrame.Database/Models/Token.cs b/src/HopFrame.Database/Models/Token.cs index b22bd21..f091123 100644 --- a/src/HopFrame.Database/Models/Token.cs +++ b/src/HopFrame.Database/Models/Token.cs @@ -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; /// /// Defines the Type of the stored Token diff --git a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs index 29deaab..b02d2fb 100644 --- a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs @@ -1,5 +1,6 @@ using HopFrame.Database.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace HopFrame.Database.Repositories.Implementation; diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index 88a95c1..f0dff7e 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -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 tokenOptions) + IOptions tokenOptions, + IOptions openIdOptions, + IUserRepository users, + IOpenIdAccessor accessor) : AuthenticationHandler(options, logger, encoder, clock) { public const string SchemeName = "HopFrame.Authentication"; @@ -30,8 +35,39 @@ public class HopFrameAuthentication( if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Query["token"]; if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided"); - + var tokenEntry = await tokens.GetToken(accessToken); + + if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled) { + 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); + return AuthenticateResult.Success(new AuthenticationTicket(identity, Scheme.Name)); + } if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); @@ -42,17 +78,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); + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } + + private async Task GenerateClaims(Token token) { var claims = new List { - 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; } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index e0b7d37..e4ccff7 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -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,12 @@ public static class HopFrameAuthenticationExtensions { service.TryAddSingleton(); service.AddScoped(); + service.AddHttpClient(); + service.AddScoped(); + service.AddOptionsFromConfiguration(configuration); service.AddOptionsFromConfiguration(configuration); + service.AddOptionsFromConfiguration(configuration); service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {}); service.AddAuthorization(); diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs new file mode 100644 index 0000000..31f0d67 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -0,0 +1,10 @@ +using HopFrame.Security.Authentication.OpenID.Models; + +namespace HopFrame.Security.Authentication.OpenID; + +public interface IOpenIdAccessor { + Task LoadConfiguration(); + Task RequestToken(string code); + Task ConstructAuthUri(string state = null); + Task InspectToken(string token); +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs new file mode 100644 index 0000000..8f70ab0 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using HopFrame.Security.Authentication.OpenID.Models; +using HopFrame.Security.Authentication.OpenID.Options; +using Microsoft.Extensions.Options; + +namespace HopFrame.Security.Authentication.OpenID.Implementation; + +internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options) : IOpenIdAccessor { + public async Task LoadConfiguration() { + 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; + + return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + } + + public async Task RequestToken(string code) { + var configuration = await LoadConfiguration(); + + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) { + Content = new FormUrlEncodedContent(new Dictionary { + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", options.Value.Callback }, + { "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(await response.Content.ReadAsStreamAsync()); + } + + public async Task ConstructAuthUri(string state = null) { + var configuration = await LoadConfiguration(); + return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={options.Value.Callback}&scope=openid%20profile%20email&state={state}"; + } + + public async Task InspectToken(string token) { + var configuration = await LoadConfiguration(); + + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, configuration.IntrospectionEndpoint) { + Content = new FormUrlEncodedContent(new Dictionary { + { "token", token }, + { "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(await response.Content.ReadAsStreamAsync()); + } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs new file mode 100644 index 0000000..60c1df2 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs @@ -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 ResponseTypesSupported { get; set; } + + [JsonPropertyName("response_modes_supported")] + public List ResponseModesSupported { get; set; } + + [JsonPropertyName("jwks_uri")] + public string JwksUri { get; set; } + + [JsonPropertyName("grant_types_supported")] + public List GrantTypesSupported { get; set; } + + [JsonPropertyName("id_token_signing_alg_values_supported")] + public List IdTokenSigningAlgValuesSupported { get; set; } + + [JsonPropertyName("subject_types_supported")] + public List SubjectTypesSupported { get; set; } + + [JsonPropertyName("token_endpoint_auth_methods_supported")] + public List TokenEndpointAuthMethodsSupported { get; set; } + + [JsonPropertyName("acr_values_supported")] + public List AcrValuesSupported { get; set; } + + [JsonPropertyName("scopes_supported")] + public List ScopesSupported { get; set; } + + [JsonPropertyName("request_parameter_supported")] + public bool RequestParameterSupported { get; set; } + + [JsonPropertyName("claims_supported")] + public List ClaimsSupported { get; set; } + + [JsonPropertyName("claims_parameter_supported")] + public bool ClaimsParameterSupported { get; set; } + + [JsonPropertyName("code_challenge_methods_supported")] + public List CodeChallengeMethodsSupported { get; set; } +} diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs new file mode 100644 index 0000000..a19b603 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs @@ -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 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 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; } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs new file mode 100644 index 0000000..042183d --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace HopFrame.Security.Authentication.OpenID.Models; + +public sealed class OpenIdToken { + [JsonPropertyName("access_token")] + public string AccessToken { 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; } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs new file mode 100644 index 0000000..483f8ac --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs @@ -0,0 +1,15 @@ +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; } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Claims/ITokenContext.cs b/src/HopFrame.Security/Claims/ITokenContext.cs index 6b052bc..6b5a590 100644 --- a/src/HopFrame.Security/Claims/ITokenContext.cs +++ b/src/HopFrame.Security/Claims/ITokenContext.cs @@ -21,6 +21,4 @@ public interface ITokenContext { /// The access token the user provided /// Token AccessToken { get; } - - IList ContextualPermissions { get; } } \ No newline at end of file diff --git a/src/HopFrame.Security/Claims/TokenContextImplementor.cs b/src/HopFrame.Security/Claims/TokenContextImplementor.cs index 47fce76..c464f23 100644 --- a/src/HopFrame.Security/Claims/TokenContextImplementor.cs +++ b/src/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -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 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 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(); } \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..c979c33 --- /dev/null +++ b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs @@ -0,0 +1,28 @@ +using HopFrame.Security.Authentication.OpenID; +using Microsoft.AspNetCore.Mvc; +using HopFrame.Security.Authentication.OpenID.Models; + +namespace HopFrame.Testing.Api.Controllers; + +public class AuthController(IOpenIdAccessor accessor) : Controller { + + [HttpGet("auth/callback")] + public async Task> Callback([FromQuery] string code, [FromQuery] string state) { + if (string.IsNullOrEmpty(code)) { + return BadRequest("Authorization code is missing"); + } + + var token = await accessor.RequestToken(code); + return Ok(token.AccessToken); + } + + [HttpGet("auth")] + public async Task Authenticate() { + return Redirect(await accessor.ConstructAuthUri()); + } + + [HttpGet("check")] + public async Task> Check([FromQuery] string token) { + return Ok(await accessor.InspectToken(token)); + } +} \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs index d097592..aa773b8 100644 --- a/testing/HopFrame.Testing.Api/Controllers/TestController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs @@ -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> Permissions() { - return new ActionResult>(userContext.ContextualPermissions); + public async Task>> Permissions() { + return new ActionResult>(await permissions.GetFullPermissions(userContext.AccessToken)); } [HttpGet("generate")] diff --git a/testing/HopFrame.Testing.Api/Program.cs b/testing/HopFrame.Testing.Api/Program.cs index 948be0d..45c9ef1 100644 --- a/testing/HopFrame.Testing.Api/Program.cs +++ b/testing/HopFrame.Testing.Api/Program.cs @@ -6,6 +6,7 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. +builder.Services.AddHttpClient(); builder.Services.AddControllers(); builder.Services.AddHopFrame(builder.Configuration); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/tests/HopFrame.Tests.Security/AuthenticationTests.cs b/tests/HopFrame.Tests.Security/AuthenticationTests.cs index 17e3d1d..3791f29 100644 --- a/tests/HopFrame.Tests.Security/AuthenticationTests.cs +++ b/tests/HopFrame.Tests.Security/AuthenticationTests.cs @@ -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())) .ReturnsAsync(new List()); - var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())); + var auth = new HopFrameAuthentication( + options.Object, + logger.Object, + encoder.Object, + clock.Object, + tokens.Object, + perms.Object, + new OptionsWrapper(new HopFrameAuthenticationOptions()), + new OptionsWrapper(new OpenIdOptions()), + new Mock().Object, + new Mock().Object); var context = new DefaultHttpContext(); if (provideCorrectToken) context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString()); From 9b38a10797c88a884e5c911adfa0efa12dd173bb Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 10:55:24 +0100 Subject: [PATCH 2/4] Added all necessary api endpoints for OpenID --- HopFrame.sln.DotSettings.user | 1 + ...ecurityController.cs => AuthController.cs} | 4 +- .../Controller/HopFrameFeatureProvider.cs | 16 ++++ .../Controller/OpenIdController.cs | 79 +++++++++++++++++++ src/HopFrame.Api/Extensions/MvcExtensions.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 12 ++- .../Logic/Implementation/AuthLogic.cs | 4 +- .../HopFrameAuthenticationExtensions.cs | 1 + .../HopFrameAuthenticationOptions.cs | 6 +- .../Authentication/OpenID/IOpenIdAccessor.cs | 1 + .../OpenID/Implementation/OpenIdAccessor.cs | 73 +++++++++++++++-- .../OpenID/Models/OpenIdToken.cs | 3 + .../OpenID/Options/OpenIdOptions.cs | 38 +++++++++ .../Controllers/AuthController.cs | 4 +- .../Controllers/TestController.cs | 6 ++ 15 files changed, 233 insertions(+), 17 deletions(-) rename src/HopFrame.Api/Controller/{SecurityController.cs => AuthController.cs} (91%) create mode 100644 src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs create mode 100644 src/HopFrame.Api/Controller/OpenIdController.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 88abaf3..c0c134b 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -74,6 +74,7 @@ + \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/SecurityController.cs b/src/HopFrame.Api/Controller/AuthController.cs similarity index 91% rename from src/HopFrame.Api/Controller/SecurityController.cs rename to src/HopFrame.Api/Controller/AuthController.cs index d9c1128..4cf4430 100644 --- a/src/HopFrame.Api/Controller/SecurityController.cs +++ b/src/HopFrame.Api/Controller/AuthController.cs @@ -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>> Login([FromBody] UserLogin login) { diff --git a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs new file mode 100644 index 0000000..c8f6cba --- /dev/null +++ b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace HopFrame.Api.Controller; + +public class HopFrameFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider { + protected override bool IsController(TypeInfo typeInfo) { + if (typeInfo.Namespace != typeof(HopFrameFeatureProvider).Namespace) + return base.IsController(typeInfo); + + if (controllerTypes.All(c => c.Name != typeInfo.Name)) + return false; + + return base.IsController(typeInfo); + } +} \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs new file mode 100644 index 0000000..16f9d3a --- /dev/null +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -0,0 +1,79 @@ +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 options) : ControllerBase { + + [HttpGet("redirect")] + public async Task RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) { + var uri = await accessor.ConstructAuthUri(redirectAfter); + + if (performRedirect == 1) { + return Redirect(uri); + } + + return Ok(new SingleValueResult(uri)); + } + + [HttpGet("callback")] + public async Task Callback([FromQuery] string code, [FromQuery] string state) { + if (string.IsNullOrEmpty(code)) { + return BadRequest("Authorization code is missing"); + } + + var token = await accessor.RequestToken(code); + + 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(token.AccessToken)); + } + + return Redirect(state.Replace("{token}", token.AccessToken)); + } + + [HttpGet("refresh")] + public async Task 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(token.AccessToken)); + } + + [HttpDelete("logout")] + public IActionResult Logout() { + Response.Cookies.Delete(ITokenContext.RefreshTokenType); + Response.Cookies.Delete(ITokenContext.AccessTokenType); + return Ok(); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/MvcExtensions.cs b/src/HopFrame.Api/Extensions/MvcExtensions.cs index d176de7..4329015 100644 --- a/src/HopFrame.Api/Extensions/MvcExtensions.cs +++ b/src/HopFrame.Api/Extensions/MvcExtensions.cs @@ -83,4 +83,4 @@ public static class MvcExtensions { return true; } } -} \ No newline at end of file +} diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 51eacde..75a0b90 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,8 +19,13 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - services.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); + var controllers = new List { typeof(AuthController) }; + + if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) + controllers.Add(typeof(OpenIdController)); + AddHopFrameNoEndpoints(services, configuration); + services.AddMvcCore().UseSpecificControllers(controllers.ToArray()); } /// @@ -30,6 +35,11 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrameNoEndpoints(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(); services.TryAddSingleton(); services.AddScoped(); diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index 61e9681..25dfd76 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -101,9 +101,7 @@ 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); + await tokens.DeleteUserTokens(tokenContext.User); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index e4ccff7..a6bf52c 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -24,6 +24,7 @@ public static class HopFrameAuthenticationExtensions { service.AddScoped(); service.AddHttpClient(); + service.AddMemoryCache(); service.AddScoped(); service.AddOptionsFromConfiguration(configuration); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs index b996d68..7cbd157 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs @@ -5,8 +5,8 @@ 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 TokenTime AccessToken { get; set; } public TokenTime RefreshToken { get; set; } @@ -16,5 +16,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); } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs index 31f0d67..df9f26f 100644 --- a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -7,4 +7,5 @@ public interface IOpenIdAccessor { Task RequestToken(string code); Task ConstructAuthUri(string state = null); Task InspectToken(string token); + Task RefreshAccessToken(string refreshToken); } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs index 8f70ab0..5161b01 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -1,12 +1,24 @@ 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 options) : IOpenIdAccessor { +internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor { + private const string DefaultCallbackEndpoint = "api/v1/openid/callback"; + + private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration"; + private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:"; + private const string TokenCacheKey = "HopFrame:OpenID:Token:"; + public async Task 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); @@ -14,10 +26,22 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var config = await JsonSerializer.DeserializeAsync(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 RequestToken(string code) { + 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}/{DefaultCallbackEndpoint}"; + var configuration = await LoadConfiguration(); var client = clientFactory.CreateClient(); @@ -25,7 +49,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions { { "grant_type", "authorization_code" }, { "code", code }, - { "redirect_uri", options.Value.Callback }, + { "redirect_uri", callback }, { "client_id", options.Value.ClientId }, { "client_secret", options.Value.ClientSecret } }) @@ -35,15 +59,27 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var token = await JsonSerializer.DeserializeAsync(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 ConstructAuthUri(string state = null) { + var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; + var configuration = await LoadConfiguration(); - return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={options.Value.Callback}&scope=openid%20profile%20email&state={state}"; + 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 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(); @@ -59,6 +95,31 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var introspection = await JsonSerializer.DeserializeAsync(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 RefreshAccessToken(string refreshToken) { + var configuration = await LoadConfiguration(); + + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) { + Content = new FormUrlEncodedContent(new Dictionary { + { "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(await response.Content.ReadAsStreamAsync()); } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs index 042183d..6303bda 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs @@ -5,6 +5,9 @@ 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; } diff --git a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs index 483f8ac..49a219c 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs @@ -12,4 +12,42 @@ public sealed class OpenIdOptions : OptionsFromConfiguration { 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; } + } } \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs index c979c33..083fd34 100644 --- a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs @@ -7,13 +7,13 @@ namespace HopFrame.Testing.Api.Controllers; public class AuthController(IOpenIdAccessor accessor) : Controller { [HttpGet("auth/callback")] - public async Task> Callback([FromQuery] string code, [FromQuery] string state) { + public async Task Callback([FromQuery] string code, [FromQuery] string state) { if (string.IsNullOrEmpty(code)) { return BadRequest("Authorization code is missing"); } var token = await accessor.RequestToken(code); - return Ok(token.AccessToken); + return Ok(token); } [HttpGet("auth")] diff --git a/testing/HopFrame.Testing.Api/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs index aa773b8..3a3affe 100644 --- a/testing/HopFrame.Testing.Api/Controllers/TestController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs @@ -66,5 +66,11 @@ public class TestController(ITokenContext userContext, DatabaseContext context, var token = await tokens.GetToken(tokenId); await tokens.DeleteToken(token); } + + [HttpGet("url")] + public async Task>> GetUrl() { + var protocol = Request.IsHttps ? "https" : "http"; + return Ok($"{protocol}://{Request.Host.Value}/auth/callback"); + } } \ No newline at end of file From bee771a30ebc5bd167b1520d9ebb94656af1ee79 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 14:28:49 +0100 Subject: [PATCH 3/4] finished OpenID integration --- HopFrame.sln.DotSettings.user | 4 ++ .../Controller/HopFrameFeatureProvider.cs | 16 ------ .../Controller/OpenIdController.cs | 9 +++- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../Logic/Implementation/AuthLogic.cs | 6 +++ .../Authentication/HopFrameAuthentication.cs | 11 ++-- .../HopFrameAuthenticationOptions.cs | 2 + .../Authentication/OpenID/IOpenIdAccessor.cs | 4 +- .../OpenID/Implementation/OpenIdAccessor.cs | 10 ++-- src/HopFrame.Web/AuthMiddleware.cs | 13 ++--- .../Services/Implementation/AuthService.cs | 52 ++++++++++++++++++- .../Controllers/AuthController.cs | 28 ---------- tests/HopFrame.Tests.Api/AuthLogicTests.cs | 18 +++---- tests/HopFrame.Tests.Web/AuthServiceTests.cs | 13 ++++- .../Pages/AuthorizedViewTests.cs | 1 + 15 files changed, 110 insertions(+), 82 deletions(-) delete mode 100644 src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs delete mode 100644 testing/HopFrame.Testing.Api/Controllers/AuthController.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index c0c134b..12afe51 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -6,6 +7,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded <AssemblyExplorer> @@ -74,6 +76,8 @@ + + diff --git a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs deleted file mode 100644 index c8f6cba..0000000 --- a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Reflection; -using Microsoft.AspNetCore.Mvc.Controllers; - -namespace HopFrame.Api.Controller; - -public class HopFrameFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider { - protected override bool IsController(TypeInfo typeInfo) { - if (typeInfo.Namespace != typeof(HopFrameFeatureProvider).Namespace) - return base.IsController(typeInfo); - - if (controllerTypes.All(c => c.Name != typeInfo.Name)) - return false; - - return base.IsController(typeInfo); - } -} \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs index 16f9d3a..50e8822 100644 --- a/src/HopFrame.Api/Controller/OpenIdController.cs +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -10,10 +10,11 @@ namespace HopFrame.Api.Controller; [ApiController, Route("api/v1/openid")] public class OpenIdController(IOpenIdAccessor accessor, IOptions options) : ControllerBase { + public const string DefaultCallback = "api/v1/openid/callback"; [HttpGet("redirect")] public async Task RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) { - var uri = await accessor.ConstructAuthUri(redirectAfter); + var uri = await accessor.ConstructAuthUri(DefaultCallback, redirectAfter); if (performRedirect == 1) { return Redirect(uri); @@ -28,7 +29,11 @@ public class OpenIdController(IOpenIdAccessor accessor, IOptions return BadRequest("Authorization code is missing"); } - var token = await accessor.RequestToken(code); + 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), diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 75a0b90..a596033 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,7 +19,10 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - var controllers = new List { typeof(AuthController) }; + var controllers = new List(); + + if (configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) + controllers.Add(typeof(AuthController)); if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) controllers.Add(typeof(OpenIdController)); diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index 25dfd76..d0d188c 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -12,6 +12,8 @@ namespace HopFrame.Api.Logic.Implementation; internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { public async Task>> Login(UserLogin login) { + if (!options.Value.DefaultAuthentication) return LogicResult>.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>> Register(UserRegister register) { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + if (register.Password.Length < 8) return LogicResult>.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>> Authenticate() { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; if (string.IsNullOrEmpty(refreshToken)) diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index f0dff7e..8fb578f 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -38,7 +38,7 @@ public class HopFrameAuthentication( var tokenEntry = await tokens.GetToken(accessToken); - if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled) { + if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled && !Guid.TryParse(accessToken, out _)) { var result = await accessor.InspectToken(accessToken); if (result is null || !result.Active) @@ -65,10 +65,13 @@ public class HopFrameAuthentication( CreatedAt = DateTime.Now, Type = Token.OpenIdTokenType }; - var identity = await GenerateClaims(token); + 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) { @@ -78,11 +81,11 @@ 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); + var principal = await GenerateClaims(tokenEntry, perms); return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); } - private async Task GenerateClaims(Token token) { + public static async Task GenerateClaims(Token token, IPermissionRepository perms) { var claims = new List { new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs index 7cbd157..8a7285f 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs @@ -8,6 +8,8 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration { 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; } diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs index df9f26f..09dc54c 100644 --- a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -4,8 +4,8 @@ namespace HopFrame.Security.Authentication.OpenID; public interface IOpenIdAccessor { Task LoadConfiguration(); - Task RequestToken(string code); - Task ConstructAuthUri(string state = null); + Task RequestToken(string code, string defaultCallback); + Task ConstructAuthUri(string defaultCallback, string state = null); Task InspectToken(string token); Task RefreshAccessToken(string refreshToken); } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs index 5161b01..3dd1a82 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -8,8 +8,6 @@ using Microsoft.Extensions.Options; namespace HopFrame.Security.Authentication.OpenID.Implementation; internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor { - private const string DefaultCallbackEndpoint = "api/v1/openid/callback"; - private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration"; private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:"; private const string TokenCacheKey = "HopFrame:OpenID:Token:"; @@ -34,13 +32,13 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions RequestToken(string code) { + public async Task 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}/{DefaultCallbackEndpoint}"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}"; var configuration = await LoadConfiguration(); @@ -67,9 +65,9 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions ConstructAuthUri(string state = null) { + public async Task 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}/{DefaultCallbackEndpoint}"; + 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}"; diff --git a/src/HopFrame.Web/AuthMiddleware.cs b/src/HopFrame.Web/AuthMiddleware.cs index b5fbc93..4a9b216 100644 --- a/src/HopFrame.Web/AuthMiddleware.cs +++ b/src/HopFrame.Web/AuthMiddleware.cs @@ -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; @@ -20,16 +19,10 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm next?.Invoke(context); return; } - - var claims = new List { - 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); diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs index 7bc38a4..5c95a8f 100644 --- a/src/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs @@ -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 options) + IOptions options, + IOptions 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 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; diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs deleted file mode 100644 index 083fd34..0000000 --- a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs +++ /dev/null @@ -1,28 +0,0 @@ -using HopFrame.Security.Authentication.OpenID; -using Microsoft.AspNetCore.Mvc; -using HopFrame.Security.Authentication.OpenID.Models; - -namespace HopFrame.Testing.Api.Controllers; - -public class AuthController(IOpenIdAccessor accessor) : Controller { - - [HttpGet("auth/callback")] - public async Task Callback([FromQuery] string code, [FromQuery] string state) { - if (string.IsNullOrEmpty(code)) { - return BadRequest("Authorization code is missing"); - } - - var token = await accessor.RequestToken(code); - return Ok(token); - } - - [HttpGet("auth")] - public async Task Authenticate() { - return Redirect(await accessor.ConstructAuthUri()); - } - - [HttpGet("check")] - public async Task> Check([FromQuery] string token) { - return Ok(await accessor.InspectToken(token)); - } -} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Api/AuthLogicTests.cs b/tests/HopFrame.Tests.Api/AuthLogicTests.cs index 39975f5..321b4a0 100644 --- a/tests/HopFrame.Tests.Api/AuthLogicTests.cs +++ b/tests/HopFrame.Tests.Api/AuthLogicTests.cs @@ -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] diff --git a/tests/HopFrame.Tests.Web/AuthServiceTests.cs b/tests/HopFrame.Tests.Web/AuthServiceTests.cs index 306a94b..56c0604 100644 --- a/tests/HopFrame.Tests.Web/AuthServiceTests.cs +++ b/tests/HopFrame.Tests.Web/AuthServiceTests.cs @@ -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(new HopFrameAuthenticationOptions())), accessor.HttpContext); + return (new AuthService( + users.Object, + accessor, + tokens.Object, + context.Object, + new OptionsWrapper(new HopFrameAuthenticationOptions()), + new OptionsWrapper(new OpenIdOptions()), + new Mock().Object, + users.Object + ), accessor.HttpContext); } private User CreateDummyUser() => new() { diff --git a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs index 92b9611..35355fd 100644 --- a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs +++ b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs @@ -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; From ffae1be340b8d4fd6eb1bf0ab707d859ed2f7c21 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 15:13:55 +0100 Subject: [PATCH 4/4] added proper documentation for openid integration --- README.md | 2 + docs/authentication.md | 4 +- docs/openid.md | 120 +++++++++++++++++++++++++++++++++++++++++ docs/readme.md | 1 + 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 docs/openid.md diff --git a/README.md b/README.md index 4d53003..b41c964 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/authentication.md b/docs/authentication.md index c3489d0..a8e29a1 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -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 diff --git a/docs/openid.md b/docs/openid.md new file mode 100644 index 0000000..00a15f4 --- /dev/null +++ b/docs/openid.md @@ -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 LoadConfiguration(); + + Task RequestToken(string code, string defaultCallback); + + Task ConstructAuthUri(string defaultCallback, string state = null); + + Task InspectToken(string token); + + Task RefreshAccessToken(string refreshToken); + +} +``` diff --git a/docs/readme.md b/docs/readme.md index df7f363..99198a9 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -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