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());