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