From 8463505a783bbd1057207fe384550b16c8d22eaf Mon Sep 17 00:00:00 2001 From: pfvatterott Date: Fri, 23 May 2025 16:18:24 -0600 Subject: [PATCH 1/3] Add Refresh Middleware Middleware that refreshes the user's information. --- PropelAuth/PropelAuthMiddleware.cs | 208 +++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 PropelAuth/PropelAuthMiddleware.cs diff --git a/PropelAuth/PropelAuthMiddleware.cs b/PropelAuth/PropelAuthMiddleware.cs new file mode 100644 index 0000000..d5cdb74 --- /dev/null +++ b/PropelAuth/PropelAuthMiddleware.cs @@ -0,0 +1,208 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using System.IdentityModel.Tokens.Jwt; + +namespace PropelAuth.Middleware +{ + /// + /// Middleware that automatically refreshes tokens on each request if they're close to expiration. + /// + public class TokenRefreshMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly string _authUrl; + private readonly string _clientId; + private readonly string _clientSecret; + + public TokenRefreshMiddleware( + RequestDelegate next, + ILogger logger, + string authUrl, + string clientId, + string clientSecret) + { + _next = next; + _logger = logger; + _authUrl = authUrl; + _clientId = clientId; + _clientSecret = clientSecret; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.User.Identity?.IsAuthenticated == true) + { + await RefreshTokenIfNeeded(context); + } + + await _next(context); + } + + private async Task RefreshTokenIfNeeded(HttpContext context) + { + try + { + var authResult = await context.AuthenticateAsync(); + + if (authResult.Succeeded && authResult.Properties != null) + { + var accessToken = authResult.Properties.GetTokenValue("access_token"); + var refreshToken = authResult.Properties.GetTokenValue("refresh_token"); + + if (ShouldRefreshToken(accessToken) && !string.IsNullOrEmpty(refreshToken)) + { + var newTokens = await RefreshAccessTokenAsync(refreshToken); + if (newTokens != null) + { + await UpdateUserTokens(context, authResult, newTokens); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred during token refresh: {Message}", ex.Message); + } + } + + private bool ShouldRefreshToken(string? accessToken) + { + if (string.IsNullOrEmpty(accessToken)) + return true; + + try + { + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(accessToken); + + // Refresh if token expires within the next 10 minutes + return jwt.ValidTo <= DateTime.UtcNow.AddMinutes(20); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not parse JWT token: {Message}", ex.Message); + return true; + } + } + + private async Task RefreshAccessTokenAsync(string refreshToken) + { + using var client = new HttpClient(); + + var tokenRequest = new Dictionary + { + {"grant_type", "refresh_token"}, + {"refresh_token", refreshToken}, + {"client_id", _clientId}, + {"client_secret", _clientSecret} + }; + + var requestContent = new FormUrlEncodedContent(tokenRequest); + + try + { + var response = await client.PostAsync($"{_authUrl}/propelauth/oauth/token", requestContent); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(content); + + return new TokenRefreshResult + { + AccessToken = json["access_token"]?.ToString(), + RefreshToken = json["refresh_token"]?.ToString(), + ExpiresIn = json["expires_in"]?.ToObject() ?? 3600 + }; + } + else + { + _logger.LogWarning("Token refresh failed with status: {StatusCode}", response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occurred while refreshing token: {Message}", ex.Message); + } + + return null; + } + + private async Task UpdateUserTokens(HttpContext context, AuthenticateResult authResult, TokenRefreshResult tokens) + { + if (authResult.Properties != null && !string.IsNullOrEmpty(tokens.AccessToken)) + { + authResult.Properties.UpdateTokenValue("access_token", tokens.AccessToken); + + if (!string.IsNullOrEmpty(tokens.RefreshToken)) + { + authResult.Properties.UpdateTokenValue("refresh_token", tokens.RefreshToken); + } + + authResult.Properties.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(tokens.ExpiresIn); + + var updatedPrincipal = UpdateClaimsFromNewToken(authResult.Principal, tokens.AccessToken); + + await context.SignInAsync(updatedPrincipal, authResult.Properties); + } + } + + private System.Security.Claims.ClaimsPrincipal UpdateClaimsFromNewToken(System.Security.Claims.ClaimsPrincipal currentPrincipal, string newAccessToken) + { + try + { + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(newAccessToken); + + var newIdentity = new System.Security.Claims.ClaimsIdentity(currentPrincipal.Identity?.AuthenticationType); + + foreach (var claim in jwt.Claims) + { + newIdentity.AddClaim(new System.Security.Claims.Claim(claim.Type, claim.Value)); + } + + var jwtClaimTypes = jwt.Claims.Select(c => c.Type).ToHashSet(); + foreach (var existingClaim in currentPrincipal.Claims) + { + if (!jwtClaimTypes.Contains(existingClaim.Type)) + { + newIdentity.AddClaim(existingClaim); + } + } + + return new System.Security.Claims.ClaimsPrincipal(newIdentity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update claims from new token, keeping existing claims"); + return currentPrincipal; + } + } + } + + public class TokenRefreshResult + { + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public int ExpiresIn { get; set; } + } + + /// + /// Extension methods for adding the token refresh middleware. + /// + public static class TokenRefreshMiddlewareExtensions + { + public static IApplicationBuilder UseTokenRefresh( + this IApplicationBuilder builder, + string authUrl, + string clientId, + string clientSecret) + { + return builder.UseMiddleware(authUrl, clientId, clientSecret); + } + } +} \ No newline at end of file From 2e36d56935daacf836e0d8076930f746facdcf28 Mon Sep 17 00:00:00 2001 From: pfvatterott Date: Wed, 28 May 2025 14:28:54 -0600 Subject: [PATCH 2/3] Added singleton so middleware has access to oauth config --- PropelAuth/PropelAuthExtensions.cs | 4 +++ PropelAuth/PropelAuthMiddleware.cs | 57 +++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/PropelAuth/PropelAuthExtensions.cs b/PropelAuth/PropelAuthExtensions.cs index 1850fb4..186b6b1 100644 --- a/PropelAuth/PropelAuthExtensions.cs +++ b/PropelAuth/PropelAuthExtensions.cs @@ -26,6 +26,9 @@ public static class PropelAuthExtensions public static async Task AddPropelAuthAsync(this IServiceCollection services, PropelAuthOptions options) { + // Register the options as a singleton so middleware can access them + services.AddSingleton(options); + // Get the public key either from options or from the PropelAuth API string publicKey = await GetPublicKeyAsync(options); @@ -137,6 +140,7 @@ private static void ConfigureAuthentication(IServiceCollection services, PropelA cookieOptions.Cookie.HttpOnly = true; cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always; cookieOptions.SlidingExpiration = true; + cookieOptions.ExpireTimeSpan = TimeSpan.FromDays(30); }) .AddOAuth("OAuth", configOptions => { diff --git a/PropelAuth/PropelAuthMiddleware.cs b/PropelAuth/PropelAuthMiddleware.cs index d5cdb74..5bda768 100644 --- a/PropelAuth/PropelAuthMiddleware.cs +++ b/PropelAuth/PropelAuthMiddleware.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; +using PropelAuth.Models; using System.IdentityModel.Tokens.Jwt; namespace PropelAuth.Middleware @@ -18,6 +20,26 @@ public class TokenRefreshMiddleware private readonly string _clientId; private readonly string _clientSecret; + // Constructor for DI-based initialization + public TokenRefreshMiddleware( + RequestDelegate next, + ILogger logger, + PropelAuthOptions options) + { + _next = next; + _logger = logger; + + if (options.OAuthOptions == null) + { + throw new InvalidOperationException("OAuth options are required for token refresh middleware. Ensure PropelAuthOptions includes OAuthOptions when calling AddPropelAuthAsync."); + } + + _authUrl = options.AuthUrl; + _clientId = options.OAuthOptions.ClientId; + _clientSecret = options.OAuthOptions.ClientSecret; + } + + // Legacy constructor for backward compatibility public TokenRefreshMiddleware( RequestDelegate next, ILogger logger, @@ -134,7 +156,7 @@ private bool ShouldRefreshToken(string? accessToken) private async Task UpdateUserTokens(HttpContext context, AuthenticateResult authResult, TokenRefreshResult tokens) { - if (authResult.Properties != null && !string.IsNullOrEmpty(tokens.AccessToken)) + if (authResult.Properties != null && !string.IsNullOrEmpty(tokens.AccessToken) && authResult.Principal != null) { authResult.Properties.UpdateTokenValue("access_token", tokens.AccessToken); @@ -196,6 +218,39 @@ public class TokenRefreshResult /// public static class TokenRefreshMiddlewareExtensions { + /// + /// Adds token refresh middleware using PropelAuthOptions from DI container. + /// This eliminates the need to specify authUrl, clientId, and clientSecret again. + /// + /// The application builder. + /// The application builder for chaining. + /// Thrown when PropelAuthOptions or OAuthOptions are not configured. + public static IApplicationBuilder UseTokenRefresh(this IApplicationBuilder builder) + { + var options = builder.ApplicationServices.GetService(); + + if (options == null) + { + throw new InvalidOperationException("PropelAuthOptions not found in DI container. Ensure you've called AddPropelAuthAsync() in your service configuration."); + } + + if (options.OAuthOptions == null) + { + throw new InvalidOperationException("OAuth options are required for token refresh middleware. Ensure PropelAuthOptions includes OAuthOptions when calling AddPropelAuthAsync."); + } + + return builder.UseMiddleware(options); + } + + /// + /// Adds token refresh middleware with explicit parameters. + /// This method is maintained for backward compatibility. + /// + /// The application builder. + /// The PropelAuth authentication URL. + /// The OAuth client ID. + /// The OAuth client secret. + /// The application builder for chaining. public static IApplicationBuilder UseTokenRefresh( this IApplicationBuilder builder, string authUrl, From 7e64795603e41ccafeee48a8004411898a7be3f1 Mon Sep 17 00:00:00 2001 From: pfvatterott Date: Thu, 29 May 2025 10:04:34 -0600 Subject: [PATCH 3/3] Added session length parameter to oauth options --- PropelAuth/PropelAuthExtensions.cs | 2 +- PropelAuth/PropelAuthOptions.cs | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/PropelAuth/PropelAuthExtensions.cs b/PropelAuth/PropelAuthExtensions.cs index 186b6b1..43a7e9a 100644 --- a/PropelAuth/PropelAuthExtensions.cs +++ b/PropelAuth/PropelAuthExtensions.cs @@ -140,7 +140,7 @@ private static void ConfigureAuthentication(IServiceCollection services, PropelA cookieOptions.Cookie.HttpOnly = true; cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always; cookieOptions.SlidingExpiration = true; - cookieOptions.ExpireTimeSpan = TimeSpan.FromDays(30); + cookieOptions.ExpireTimeSpan = TimeSpan.FromDays(options.OAuthOptions.SessionLength ?? 14); }) .AddOAuth("OAuth", configOptions => { diff --git a/PropelAuth/PropelAuthOptions.cs b/PropelAuth/PropelAuthOptions.cs index 9edc63b..af6a663 100644 --- a/PropelAuth/PropelAuthOptions.cs +++ b/PropelAuth/PropelAuthOptions.cs @@ -76,11 +76,16 @@ public class OAuthOptions /// Whether to allow requests via an authorization header `Bearer {TOKEN}`. Default false. /// public bool? AllowBearerTokenAuth { get; } - + + /// + /// The amount of days the user's session should stay active for. Defaults to 14 days. + /// + public double? SessionLength { get; } + #endregion #region Constructor - + /// /// Initializes a new instance of the class. /// @@ -88,12 +93,14 @@ public class OAuthOptions /// The client secret for the OAuth application. /// Optional. The callback path for the OAuth application. Defaults to "/callback" /// Optional. Whether to allow requests via an authorization header `Bearer {TOKEN}`. Default false. - public OAuthOptions(string clientId, string clientSecret, string? callbackPath = "/callback", bool? allowBearerTokenAuth = false) + /// Optional. The amount of days the user's session should stay active for. Defaults to 14 days. + public OAuthOptions(string clientId, string clientSecret, string? callbackPath = "/callback", bool? allowBearerTokenAuth = false, double? sessionLength = 14) { ClientId = clientId; ClientSecret = clientSecret; CallbackPath = callbackPath; AllowBearerTokenAuth = allowBearerTokenAuth; + SessionLength = sessionLength; } #endregion