From 2347e230d2a6bc527c97a40b93eab18b90907cc4 Mon Sep 17 00:00:00 2001 From: Fritz Seitz Date: Sat, 28 Sep 2024 16:59:27 -0400 Subject: [PATCH] feat: added better exception handling for expired refresh token --- src/Bulwark.Auth.Core/Authentication.cs | 30 ++++++++------ src/Bulwark.Auth.Core/JwtTokenizer.cs | 39 +++++++++---------- src/Bulwark.Auth/.env | 10 ++--- src/Bulwark.Auth/AppConfig.cs | 28 ++++++------- .../Controllers/AuthenticationController.cs | 17 +++++--- src/Bulwark.Auth/Program.cs | 2 +- .../JwtTokenizerTests.cs | 29 ++++++++++++-- .../Mocks/MockSocialValidator.cs | 8 ++-- 8 files changed, 96 insertions(+), 67 deletions(-) diff --git a/src/Bulwark.Auth.Core/Authentication.cs b/src/Bulwark.Auth.Core/Authentication.cs index d108ae8..1f71b87 100644 --- a/src/Bulwark.Auth.Core/Authentication.cs +++ b/src/Bulwark.Auth.Core/Authentication.cs @@ -5,10 +5,11 @@ using Bulwark.Auth.Repositories.Exception; using Bulwark.Auth.Repositories.Model; using Bulwark.Auth.Repositories.Util; +using JWT.Exceptions; namespace Bulwark.Auth.Core; -public class Authentication +public class Authentication { private readonly JwtTokenizer _tokenizer; private readonly IAccountRepository _accountRepository; @@ -32,7 +33,7 @@ public Authentication( /// /// Classic authentication, user/password if successfully authenticated tokens are returned to - /// client. The authenticated tokens must be acknowledged by the client before proceeding + /// client. The authenticated tokens must be acknowledged by the client before proceeding /// /// /// @@ -87,7 +88,7 @@ await _tokenRepository.Acknowledge(accountModel.Id, } catch(BulwarkDbException exception) { - throw new BulwarkAuthenticationException("Cannot acknowledge token", + throw new BulwarkAuthenticationException("Cannot acknowledge token", exception); } } @@ -127,12 +128,16 @@ public async Task ValidateAccessToken(string email, return token ?? null; } + catch (TokenExpiredException exception) + { + throw new BulwarkTokenExpiredException("Access Token Expired", exception); + } catch (BulwarkDbException exception) { throw new BulwarkTokenException("Tokens cannot be validated", exception); } } - + /// /// Renews an acknowledged token when the access token is expired. /// Use case: when an account access token expires use the refresh token to get a new set of tokens @@ -145,7 +150,7 @@ public async Task ValidateAccessToken(string email, /// /// public async Task Renew(string email, string refreshToken, - string deviceId, string tokenizerName = "jwt") + string deviceId) { try { @@ -175,16 +180,20 @@ public async Task Renew(string email, string refreshToken, return authenticated; } + catch (TokenExpiredException exception) + { + throw new BulwarkTokenExpiredException("Refresh Token Expired", exception); + } catch (BulwarkDbException exception) { throw new BulwarkAuthenticationException("Cannot renew tokens", exception); } } - + /// /// Deletes an acknowledged token. - /// Use case: logging users out of a device. + /// Use case: logging users out of a device. /// /// /// @@ -192,7 +201,7 @@ public async Task Renew(string email, string refreshToken, /// public async Task Revoke(string email, string accessToken, string deviceId) - { + { if(await ValidateAccessToken(email, accessToken, deviceId) != null) { try{ @@ -213,7 +222,7 @@ public async Task Revoke(string email, string accessToken, /// private static void CheckAccountHealth(AccountModel account) { - //Keep this order :) + //Keep this order :) if (account.IsDeleted) { throw new BulwarkAccountException("Account deleted"); @@ -229,5 +238,4 @@ private static void CheckAccountHealth(AccountModel account) throw new BulwarkAccountException("Account disabled"); } } -} - +} \ No newline at end of file diff --git a/src/Bulwark.Auth.Core/JwtTokenizer.cs b/src/Bulwark.Auth.Core/JwtTokenizer.cs index b51cc65..8df57d1 100644 --- a/src/Bulwark.Auth.Core/JwtTokenizer.cs +++ b/src/Bulwark.Auth.Core/JwtTokenizer.cs @@ -22,12 +22,12 @@ public class JwtTokenizer private readonly SortedList _keys = new(); private readonly Dictionary _signingAlgorithms = new(); - private readonly int _accessTokenExpirationInMins; - private readonly int _refreshTokenExpirationInHours; + private readonly int _accessTokenExpirationInSecs; + private readonly int _refreshTokenExpirationInSecs; - public JwtTokenizer(string issuer, string audience, - int accessTokenExpInMin, - int refreshTokenExpInHours, + public JwtTokenizer(string issuer, string audience, + int accessTokenExpInSec, + int refreshTokenExpInSecs, List signingAlgorithms, SigningKey signingKey) { @@ -41,12 +41,12 @@ public JwtTokenizer(string issuer, string audience, { _signingAlgorithms.Add(alg.Name, alg); } - + Name = "jwt"; Issuer = issuer; Audience = audience; - _accessTokenExpirationInMins = accessTokenExpInMin; - _refreshTokenExpirationInHours = refreshTokenExpInHours; + _accessTokenExpirationInSecs = accessTokenExpInSec; + _refreshTokenExpirationInSecs = refreshTokenExpInSecs; } /// @@ -76,15 +76,15 @@ public string CreateAccessToken(string userId, List roles, List .AddClaim("roles", roles) .AddClaim("permissions", permissions) .AddClaim("exp", - DateTimeOffset.UtcNow.AddMinutes(_accessTokenExpirationInMins) + DateTimeOffset.UtcNow.AddSeconds(_accessTokenExpirationInSecs) .ToUnixTimeSeconds()) .AddClaim("sub", userId) - + .Encode(); return token; } - + /// /// This will create a refresh token for a user. Refresh tokens are longer lived tokens that can be used to /// create new access tokens. @@ -99,7 +99,7 @@ public string CreateRefreshToken(string userId) { throw new BulwarkTokenException("No keys found"); } - + var token = JwtBuilder.Create() .WithAlgorithm(_signingAlgorithms[latestKey.Algorithm.ToUpper()].GetAlgorithm(latestKey.PrivateKey, latestKey.PublicKey)) @@ -108,14 +108,14 @@ public string CreateRefreshToken(string userId) .Id(Guid.NewGuid().ToString()) .Issuer(Issuer) .Audience(Audience) - .AddClaim("exp", DateTimeOffset.UtcNow.AddHours(_refreshTokenExpirationInHours) + .AddClaim("exp", DateTimeOffset.UtcNow.AddSeconds(_refreshTokenExpirationInSecs) .ToUnixTimeSeconds()) .AddClaim("sub", userId) .Encode(); return token; } - + public AccessToken ValidateAccessToken(string userId, string token) { var json = ValidateToken(userId,token); @@ -123,8 +123,8 @@ public AccessToken ValidateAccessToken(string userId, string token) .Deserialize(json); return accessToken; } - - + + public RefreshToken ValidateRefreshToken(string userId, string token) { var json = ValidateToken(userId, token); @@ -133,7 +133,7 @@ public RefreshToken ValidateRefreshToken(string userId, string token) return refreshToken; } /// - /// This will validate a refresh or access token + /// This will validate a refresh or access token /// /// /// @@ -147,7 +147,7 @@ public string ValidateToken(string userId, string token) { throw new BulwarkTokenException("Token user miss match"); } - + var keyId = decodedValue.Header["kid"].ToString(); var key = GetKey(keyId); @@ -179,5 +179,4 @@ private Key GetKey(string keyId) { return _keys.Values.FirstOrDefault(x => x.KeyId == keyId); } -} - +} \ No newline at end of file diff --git a/src/Bulwark.Auth/.env b/src/Bulwark.Auth/.env index 724e56a..603e49d 100644 --- a/src/Bulwark.Auth/.env +++ b/src/Bulwark.Auth/.env @@ -18,10 +18,6 @@ VERIFICATION_URL=https://localhost:3000/verify FORGOT_PASSWORD_URL=https://localhost:3000/reset-password MAGIC_LINK_URL=https://localhost:3000/magic-link MAGIC_CODE_EXPIRE_IN_MINUTES=10 -ACCESS_TOKEN_EXPIRE_IN_MINUTES=5 -REFRESH_TOKEN_EXPIRE_IN_HOURS=1 -SERVICE_MODE=test - - - - +ACCESS_TOKEN_EXPIRE_IN_SECONDS=300 +REFRESH_TOKEN_EXPIRE_IN_SECONDS=3600 +SERVICE_MODE=test \ No newline at end of file diff --git a/src/Bulwark.Auth/AppConfig.cs b/src/Bulwark.Auth/AppConfig.cs index 721fab3..dc8e797 100644 --- a/src/Bulwark.Auth/AppConfig.cs +++ b/src/Bulwark.Auth/AppConfig.cs @@ -24,8 +24,8 @@ public class AppConfig public string ForgotPasswordUrl { get; } public string EmailFromAddress { get; } public int MagicCodeExpireInMinutes { get; } - public int AccessTokenExpireInMinutes { get; } - public int RefreshTokenExpireInHours { get; } + public int AccessTokenExpireInSeconds { get; } + public int RefreshTokenExpireInSeconds { get; } public AppConfig() { DbConnection = Environment.GetEnvironmentVariable("DB_CONNECTION") ?? "mongodb://localhost:27017"; @@ -34,21 +34,21 @@ public AppConfig() MicrosoftClientId = Environment.GetEnvironmentVariable("MICROSOFT_CLIENT_ID") ?? string.Empty; MicrosoftTenantId = Environment.GetEnvironmentVariable("MICROSOFT_TENANT_ID") ?? string.Empty; GithubAppName = Environment.GetEnvironmentVariable("GITHUB_APP_NAME") ?? string.Empty; - Domain = Environment.GetEnvironmentVariable("DOMAIN") ?? + Domain = Environment.GetEnvironmentVariable("DOMAIN") ?? throw new BulwarkAuthConfigException("DOMAIN environment variable is required."); - WebsiteName = Environment.GetEnvironmentVariable("WEBSITE_NAME") ?? + WebsiteName = Environment.GetEnvironmentVariable("WEBSITE_NAME") ?? throw new BulwarkAuthConfigException("WEBSITE_NAME environment variable is required."); - EmailTemplateDir = Environment.GetEnvironmentVariable("EMAIL_TEMPLATE_DIR") ?? + EmailTemplateDir = Environment.GetEnvironmentVariable("EMAIL_TEMPLATE_DIR") ?? "Templates/Email/"; - EmailFromAddress = Environment.GetEnvironmentVariable("EMAIL_FROM_ADDRESS") ?? + EmailFromAddress = Environment.GetEnvironmentVariable("EMAIL_FROM_ADDRESS") ?? throw new BulwarkAuthConfigException( "EMAIL_FROM_ADDRESS environment variable is required."); EmailFromAddress = EmailFromAddress.Trim(); EnableSmtp = Environment.GetEnvironmentVariable("ENABLE_SMTP")?.ToLower() == "true"; - + if (EnableSmtp) { - EmailSmtpHost = Environment.GetEnvironmentVariable("EMAIL_SMTP_HOST") ?? + EmailSmtpHost = Environment.GetEnvironmentVariable("EMAIL_SMTP_HOST") ?? throw new BulwarkAuthConfigException( "EMAIL_SMTP_HOST environment variable is required when SMTP is enabled."); EmailSmtpPort = int.TryParse(Environment.GetEnvironmentVariable("EMAIL_SMTP_PORT"), out var port) @@ -58,19 +58,19 @@ public AppConfig() EmailSmtpPass = Environment.GetEnvironmentVariable("EMAIL_SMTP_PASS") ?? String.Empty; EmailSmtpSecure = Environment.GetEnvironmentVariable("EMAIL_SMTP_SECURE")?.ToLower() == "true"; } - - VerificationUrl = Environment.GetEnvironmentVariable("VERIFICATION_URL") ?? + + VerificationUrl = Environment.GetEnvironmentVariable("VERIFICATION_URL") ?? throw new BulwarkAuthConfigException("VERIFICATION_URL environment variable is required."); - ForgotPasswordUrl = Environment.GetEnvironmentVariable("FORGOT_PASSWORD_URL") ?? + ForgotPasswordUrl = Environment.GetEnvironmentVariable("FORGOT_PASSWORD_URL") ?? throw new BulwarkAuthConfigException("FORGOT_PASSWORD_URL environment variable is required."); - EmailFromAddress = Environment.GetEnvironmentVariable("EMAIL_FROM_ADDRESS") ?? + EmailFromAddress = Environment.GetEnvironmentVariable("EMAIL_FROM_ADDRESS") ?? throw new BulwarkAuthConfigException( "EMAIL_FROM_ADDRESS environment variable is required."); MagicCodeExpireInMinutes = int.TryParse(Environment.GetEnvironmentVariable("MAGIC_CODE_EXPIRE_IN_MINUTES"), out var expireInMinutes) ? expireInMinutes : 10; - AccessTokenExpireInMinutes = int.TryParse(Environment.GetEnvironmentVariable("ACCESS_TOKEN_EXPIRE_IN_MINUTES"), + AccessTokenExpireInSeconds = int.TryParse(Environment.GetEnvironmentVariable("ACCESS_TOKEN_EXPIRE_IN_MINUTES"), out var accessExpireInMinutes) ? accessExpireInMinutes : 30; - RefreshTokenExpireInHours = int.TryParse(Environment.GetEnvironmentVariable("REFRESH_TOKEN_EXPIRE_IN_HOURS"), + RefreshTokenExpireInSeconds = int.TryParse(Environment.GetEnvironmentVariable("REFRESH_TOKEN_EXPIRE_IN_HOURS"), out var refreshExpireInHours) ? refreshExpireInHours : 24; } } \ No newline at end of file diff --git a/src/Bulwark.Auth/Controllers/AuthenticationController.cs b/src/Bulwark.Auth/Controllers/AuthenticationController.cs index ec87e00..0b85a0c 100644 --- a/src/Bulwark.Auth/Controllers/AuthenticationController.cs +++ b/src/Bulwark.Auth/Controllers/AuthenticationController.cs @@ -93,13 +93,21 @@ public async Task> RenewCredentials(RefreshToken pay return authenticated; } + catch (BulwarkTokenExpiredException exception) + { + return Problem( + title: "Refresh Expired Token", + detail: exception.Message, + statusCode: StatusCodes.Status422UnprocessableEntity + ); + } catch (BulwarkTokenException exception) { return Problem( title: "Bad Tokens", detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity - ); + ); } } @@ -121,7 +129,6 @@ await _auth.Revoke(validate.Email, detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity ); - } - } -} - + } + } +} \ No newline at end of file diff --git a/src/Bulwark.Auth/Program.cs b/src/Bulwark.Auth/Program.cs index 0abd7cc..fc66fd7 100644 --- a/src/Bulwark.Auth/Program.cs +++ b/src/Bulwark.Auth/Program.cs @@ -86,7 +86,7 @@ applicationBuilder.Services.AddSingleton(passwordPolicy); applicationBuilder.Services.AddSingleton(t => new JwtTokenizer("bulwark", "bulwark", - appConfig.AccessTokenExpireInMinutes, appConfig.RefreshTokenExpireInHours, + appConfig.AccessTokenExpireInSeconds, appConfig.RefreshTokenExpireInSeconds, signingAlgorithms, t.GetService())); applicationBuilder.Services.AddSingleton(mongoClient.GetDatabase(dbName)); applicationBuilder.Services.AddTransient(); diff --git a/tests/Bulwark.Auth.Core.Tests/JwtTokenizerTests.cs b/tests/Bulwark.Auth.Core.Tests/JwtTokenizerTests.cs index 6add3c8..68e38f6 100644 --- a/tests/Bulwark.Auth.Core.Tests/JwtTokenizerTests.cs +++ b/tests/Bulwark.Auth.Core.Tests/JwtTokenizerTests.cs @@ -4,15 +4,19 @@ using Bulwark.Auth.Core.Util; using Bulwark.Auth.Repositories; using Bulwark.Auth.TestFixture; +using JWT.Exceptions; +using Xunit.Abstractions; namespace Bulwark.Auth.Core.Tests; public class JwtTokenizerTests : IClassFixture { + private readonly ITestOutputHelper _testOutputHelper; private readonly JwtTokenizer _tokenizer; - public JwtTokenizerTests(MongoDbRandomFixture dbFixture) + public JwtTokenizerTests(MongoDbRandomFixture dbFixture, ITestOutputHelper testOutputHelper) { + _testOutputHelper = testOutputHelper; var signingKey = new SigningKey(new MongoDbSigningKey(dbFixture.Db)); var key = RsaKeyGenerator.MakeKey(); var keys = new Key[1]; @@ -21,7 +25,7 @@ public JwtTokenizerTests(MongoDbRandomFixture dbFixture) { new Rsa256() }; - _tokenizer = new JwtTokenizer("test", "test", 10,24, + _tokenizer = new JwtTokenizer("test", "test", 2,2, signingAlgorithms,signingKey); } @@ -32,7 +36,7 @@ public void CreateAccessTokenAndValidate() var permissions = new List() { "permission1", "permission2" }; var jwt = _tokenizer.CreateAccessToken("userid", roles, permissions); var accessToken = _tokenizer.ValidateAccessToken("userid", jwt); - + Assert.True(accessToken.Sub == "userid"); } @@ -44,5 +48,22 @@ public void CreateRefreshTokenAndValidate() var refreshToken = _tokenizer.ValidateRefreshToken(account,jwt); Assert.True(refreshToken.Sub == account); } -} + [Fact] + public async Task RefreshTokenAndValidateExpired() + { + var account = Guid.NewGuid().ToString() + "@bulwark.io"; + var jwt = _tokenizer.CreateRefreshToken(account); + await Task.Delay(2500); + try + { + var refreshToken = _tokenizer.ValidateRefreshToken(account, jwt); + Assert.False(refreshToken.Sub == account); + } + catch (TokenExpiredException e) + { + _testOutputHelper.WriteLine(e.Message); + Assert.True(true); + } + } +} \ No newline at end of file diff --git a/tests/Bulwark.Auth.Core.Tests/Mocks/MockSocialValidator.cs b/tests/Bulwark.Auth.Core.Tests/Mocks/MockSocialValidator.cs index 0416358..acfb8ca 100644 --- a/tests/Bulwark.Auth.Core.Tests/Mocks/MockSocialValidator.cs +++ b/tests/Bulwark.Auth.Core.Tests/Mocks/MockSocialValidator.cs @@ -20,17 +20,15 @@ public MockSocialValidator(string name) { if(token == "validtoken") { - return new Social.Social() + return await Task.FromResult(new Social.Social() { Email = "test@lateflip.io", Provider = Name, SocialId = "email" - }; + }); } throw new BulwarkSocialException("Social token cannot be validated"); } -} - - +} \ No newline at end of file