Skip to content

Commit

Permalink
feat: Adding Token Store to properly manage signout & invalidating token
Browse files Browse the repository at this point in the history
  • Loading branch information
dansiegel committed Jan 31, 2023
1 parent 2f9df13 commit a7266d5
Show file tree
Hide file tree
Showing 15 changed files with 295 additions and 82 deletions.
6 changes: 4 additions & 2 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,12 @@ By Default the library will attempt to return the following claims:
- The Access & Refresh Tokens
- When the Token Expires as a UTC time in Unix Seconds

Whether you need to inject some additional logic or if you just want to customize how the claims are returned, it is very easy to do. You simply need to implement `IMobileAuthClaimsHandler` and register it with the `IServiceCollection` like so:
Whether you need to inject some additional logic or if you just want to customize how the claims are returned, it is very easy to do. You simply need to implement `IMobileAuthClaimsHandler` and register it with the `MobileAuthenticationBuilder` like so:

```cs
builder.Services.AddScoped<IMobileAuthClaimsHandler, MyCustomMobileAuthClaimsHandler>();
builder.AddMobileAuth(auth => {
auth.AddMobileAuthClaimsHandler<MyCustomMobileAuthClaimsHandler>();
});
```

## Run The Sample
Expand Down
33 changes: 0 additions & 33 deletions sample/DemoAPI/CustomTokenService.cs

This file was deleted.

27 changes: 25 additions & 2 deletions sample/DemoAPI/Data/UserContext.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
using Microsoft.EntityFrameworkCore;
using AvantiPoint.MobileAuth.Stores;
using Microsoft.EntityFrameworkCore;

namespace DemoAPI.Data;

public class UserContext : DbContext
public class UserContext : DbContext, ITokenStore
{
public UserContext(DbContextOptions options)
: base(options)
Expand All @@ -12,4 +13,26 @@ public UserContext(DbContextOptions options)
public DbSet<AuthorizedTokens> AuthorizedTokens { get; set; }

public DbSet<UserRole> UserRoles { get; set; }

public async ValueTask AddToken(string jwt, DateTimeOffset expires)
{
await AuthorizedTokens.AddAsync(new Data.AuthorizedTokens()
{
Token = jwt
});
await SaveChangesAsync();
}

public async ValueTask RemoveToken(string jwt)
{
var tokens = await AuthorizedTokens.Where(x => x.Token == jwt).ToArrayAsync();
if(tokens.Any())
{
AuthorizedTokens.RemoveRange(tokens);
await SaveChangesAsync();
}
}

public async ValueTask<bool> TokenExists(string jwt) =>
await AuthorizedTokens.AnyAsync(x => x.Token == jwt);
}
27 changes: 15 additions & 12 deletions sample/DemoAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@

var builder = WebApplication.CreateBuilder(args);

// Add Custom Overrides for Token Validation & User Claims
builder.Services.AddScoped<ITokenService, CustomTokenService>()
.AddScoped<IMobileAuthClaimsHandler, CustomClaimsHandler>();

builder.AddMobileAuth(builder =>
builder.AddMobileAuth(auth =>
{
// Configure override for Token Store
auth.ConfigureDbTokenStore<UserContext>(o => o.UseInMemoryDatabase("DemoApi"));
// Configure override for Claims Handler
auth.AddMobileAuthClaimsHandler<CustomClaimsHandler>();
// Add Additional Providers like Facebook, Twitter, LinkedIn, GitHub, etc...
});

builder.Services.AddDbContext<UserContext>(o => o.UseInMemoryDatabase("DemoApi"));

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
Expand All @@ -24,16 +23,20 @@
var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseSwagger();
app.UseSwaggerUI();

app.UseAuthentication();
app.UseAuthorization();

app.Map("/", async context =>
{
await Task.CompletedTask;
context.Response.Redirect("/swagger");
});

// maps https://{host}/mobileauth/{Apple|Google|Microsoft}
app.MapDefaultMobileAuthRoutes();
//app.MapMobileAuthRoute();
Expand Down
2 changes: 1 addition & 1 deletion sample/DemoMobileApp/ViewModels/MainPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ private async void OnLoginCommandExecuted(string scheme)
var result = await _webAuthenticator.AuthenticateAsync(new WebAuthenticatorOptions
{
CallbackUrl = new Uri($"{Constants.CallbackScheme}://"),
Url = new Uri(Constants.BaseUrl)
Url = new Uri(new Uri(Constants.BaseUrl), $"mobileauth/{scheme}")
});

await _storage.SetAsync("access_token", result.AccessToken);
Expand Down
2 changes: 2 additions & 0 deletions src/AvantiPoint.MobileAuth/Authentication/ITokenOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
public interface ITokenOptions
{
string? JwtKey { get; }
bool OverrideTokenExpiration { get; }
TimeSpan DefaultExpiration { get; }
}
42 changes: 34 additions & 8 deletions src/AvantiPoint.MobileAuth/Authentication/TokenService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using AvantiPoint.MobileAuth.Stores;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -11,46 +12,63 @@ public class TokenService : ITokenService
{
private ITokenOptions _options { get; }

private ITokenStore _tokenStore { get; }

private IHttpContextAccessor _contextAccessor { get; }

private ILogger _logger { get; }

public TokenService(IHttpContextAccessor contextAccessor, ILogger<TokenService> logger, ITokenOptions options)
public TokenService(IHttpContextAccessor contextAccessor, ILogger<TokenService> logger, ITokenStore tokenStore, ITokenOptions options)
{
_contextAccessor = contextAccessor;
_logger = logger;
_options = options;
_tokenStore = tokenStore;
}

public async ValueTask<string> BuildToken(IEnumerable<Claim> userClaims)
{
var expires = DateTimeOffset.UtcNow.AddMinutes(30);
var defaultExpiration = _options.DefaultExpiration == default ? TimeSpan.FromMinutes(30) : _options.DefaultExpiration;

var expires = DateTimeOffset.UtcNow.Add(defaultExpiration);
if (userClaims.ContainsKey("expires_in") &&
long.TryParse(userClaims.FindFirstValue("expires_in"), out var expires_in) &&
expires_in > 0)
expires = DateTimeOffset.FromUnixTimeSeconds(expires_in);
{
if(_options.OverrideTokenExpiration)
expires = DateTimeOffset.FromUnixTimeSeconds(expires_in);
}
else if(_options.OverrideTokenExpiration)
{
_logger.LogInformation("Unable to override token expiration. The provided OAuth token does not have a valid `expires_in` claim.");
}

var claims = userClaims.Where(x => !string.IsNullOrEmpty(x.Value) && x.Value != "-1");

var host = GetHost();
_logger.LogInformation($"Using '{host}' for the token host.");
var securityKey = GetKey();
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken(host, host, claims,
expires: expires.UtcDateTime, signingCredentials: credentials);
var jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
await _tokenStore.AddToken(jwt, expires);
await OnTokenCreated(tokenDescriptor, jwt);
return jwt;
}

protected virtual ValueTask OnTokenCreated(JwtSecurityToken securityToken, string jwt) => ValueTask.CompletedTask;

public virtual ValueTask<bool> IsTokenValid(string token)
public virtual async ValueTask<bool> IsTokenValid(string token)
{
if (string.IsNullOrEmpty(token))
return ValueTask.FromResult(false);
return false;

try
{
if (!await _tokenStore.TokenExists(token))
return false;

var host = GetHost();
var tokenHandler = new JwtSecurityTokenHandler();
tokenHandler.ValidateToken(token, new TokenValidationParameters
Expand All @@ -64,15 +82,23 @@ public virtual ValueTask<bool> IsTokenValid(string token)
}, out SecurityToken validatedToken);

var now = DateTime.UtcNow;
return ValueTask.FromResult(now >= validatedToken.ValidFrom && now <= validatedToken.ValidTo);
return now >= validatedToken.ValidFrom && now <= validatedToken.ValidTo;
}
catch
{
return ValueTask.FromResult(false);
return false;
}
}

public virtual ValueTask InvalidateToken(string token) => ValueTask.CompletedTask;
public async ValueTask InvalidateToken(string token)
{
_logger.LogInformation("Invalidating Token.");
await _tokenStore.RemoveToken(token);
_logger.LogInformation("Token Invalidated.");
await OnTokenInvalidated(token);
}

protected virtual ValueTask OnTokenInvalidated(string token) => ValueTask.CompletedTask;

public SymmetricSecurityKey GetKey()
{
Expand Down
3 changes: 3 additions & 0 deletions src/AvantiPoint.MobileAuth/AvantiPoint.MobileAuth.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="FileContextCore" Version="3.4.0" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.4.0" />
</ItemGroup>

Expand All @@ -26,6 +27,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="6.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="6.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.13" />
</ItemGroup>
</When>
<When Condition="$(TargetFramework) == 'net7.0'">
Expand All @@ -35,6 +37,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.2" />
</ItemGroup>
</When>
</Choose>
Expand Down
30 changes: 30 additions & 0 deletions src/AvantiPoint.MobileAuth/Configuration/OAuthLibraryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,43 @@ namespace AvantiPoint.MobileAuth.Configuration;

internal class OAuthLibraryOptions : ITokenOptions
{
public string? AuthPath { get; set; }

public string? CallbackScheme { get; set; }

public string? JwtKey { get; set; }

public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromMinutes(30);

public bool OverrideTokenExpiration { get; set; }

public AppleOAuthOptions? Apple { get; set; }

public GoogleProviderOptions? Google { get; set; }

public MicrosoftProviderOptions? Microsoft { get; set; }

internal string Signin => $"/{SanitizePath()}/signin";

internal string Signout => $"/{SanitizePath()}/signout";

internal string Refresh => $"/{SanitizePath()}/refresh";

private string SanitizePath()
{
var path = AuthPath;
if (!string.IsNullOrEmpty(path))
{
if (path.EndsWith('/'))
path = path.Substring(0, path.Length - 1);

if (path.StartsWith('/'))
path = path.Substring(1);
}

if (string.IsNullOrEmpty(path))
path = "mobileauth";

return path;
}
}
21 changes: 21 additions & 0 deletions src/AvantiPoint.MobileAuth/Http/HttpContextHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Http;

namespace AvantiPoint.MobileAuth.Http;

internal static class HttpContextHelpers
{
public static Task StatusCode(this HttpContext context, int statusCode)
{
context.Response.StatusCode = statusCode;
return context.Response.Body.FlushAsync();
}

public static Task Ok(this HttpContext context)
=> context.StatusCode(StatusCodes.Status200OK);

public static Task BadRequest(this HttpContext context)
=> context.StatusCode(StatusCodes.Status400BadRequest);

public static Task NoContent(this HttpContext context)
=> context.StatusCode(StatusCodes.Status204NoContent);
}
Loading

0 comments on commit a7266d5

Please sign in to comment.