diff --git a/README.md b/README.md index c70d57b..e9e2af0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Simple Token Provider Middleware for ASP.NET -This project demonstrates how to generate [JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token) (JWTs) for token authentication in ASP.NET Core RC2. The functionality is wrapped up in a reusable middleware component. +This project demonstrates how to generate [JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token) (JWTs) for token authentication and how to generate a refreshed token in ASP.NET Core RC2. The functionality is wrapped up in a reusable middleware component. Original blog post: [Token Authentication in ASP.NET Core](https://stormpath.com/blog/token-authentication-asp-net-core) @@ -14,6 +14,7 @@ The token provider endpoint can be added to your pipeline in `Configure()`: app.UseSimpleTokenProvider(new TokenProviderOptions { Path = "/api/token", + RefreshPath = "api/refresh-token", Audience = "ExampleAudience", Issuer = "ExampleIssuer", SigningCredentials = signingCredentials, @@ -24,6 +25,7 @@ app.UseSimpleTokenProvider(new TokenProviderOptions The options are: * **Path** (optional) - The endpoint path relative to the server root. Default: `/token` +* **RefreshPath** (optional) - The endpoint path relative to the server root used for token refresh. Default: `/refresh-token` * **Audience** - The JWT `aud` claim value. * **Issuer** - The JWT `iss` claim value. * **Expiration** (optional) - The expiration duration for new tokens. Default: 5 minutes @@ -65,6 +67,7 @@ private Task GetIdentity(string username, string password) At a high level, the middleware does the following: +### Create * Intercepts requests to `options.Path` * Verifies the request is a POST with `Content-Type: application/x-www-form-urlencoded` * Pulls the username and password out of the form body @@ -79,6 +82,13 @@ At a high level, the middleware does the following: * `aud` (audience) - `options.Audience` * Encodes the JWT to a string and sends it back to the client +### Refresh +* Intercepts requests to `options.RefreshPath` +* Verifies the request is a POST with `Content-Type: application/x-www-form-urlencoded` (probably not required) +* Validates passed JWT. +* Based on passed token it creates a new one with new expiration date. +* Encodes the new JWT to a string and sends it back to the client + ## Trying it out You can install the middleware in a new project, or just run the included test project. Send a POST request using a tool like Fiddler or Postman: @@ -99,6 +109,24 @@ You should get a `200 OK` response: } ``` +And for refresh: + +``` +POST /refresh-token (or whatever you set options.RefreshPath to) +Content-Type: application/x-www-form-urlencoded +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJURVNUIiwianRpIjoiYzRjYzdhMmUtMjI0OS00ZWUzLWJkM2MtYzU5MDkzYmU5MGU1IiwiaWF0IjoxNDYzNTMwMDI0LCJuYmYiOjE0NjM1MzAwMjMsImV4cCI6MTQ2MzUzMDMyMywiaXNzIjoiRXhhbXBsZUlzc3VlciIsImF1ZCI6IkV4YW1wbGVBdWRpZW5jZSJ9.mI0NPO437IuBSt5kmayy5XhNFEHVF4IyMkKsmtas6w8 + +``` + +You should get a `200 OK` response: + +``` +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJURVNUIiwianRpIjoiNjY1NjBlMTctZGRmNy00MWNhLWE1NWMtMjgxNmZjZjU0NzU2IiwiaWF0IjoxNDgwNjcwMjIxLCJuYmYiOjE0ODA2NzAyNzAsImV4cCI6MTQ4MDY3MDU3MCwiaXNzIjoiRXhhbXBsZUlzc3VlciIsImF1ZCI6WyJFeGFtcGxlQXVkaWVuY2UiLCJFeGFtcGxlQXVkaWVuY2UiLCJFeGFtcGxlQXVkaWVuY2UiXX0.9WMoq2V0SE8ECs2Qzk2ymGL-BeYrPJc9_oRkLhdmW2g", + "expires_in": 300 +} +``` + You can try decoding and verifying the JWT at [jsonwebtoken.io](https://jsonwebtoken.io). ## Acknowledgements diff --git a/src/SimpleTokenProvider/TokenProviderAppBuilderExtensions.cs b/src/SimpleTokenProvider/TokenProviderAppBuilderExtensions.cs index 4ba261c..3f84efb 100644 --- a/src/SimpleTokenProvider/TokenProviderAppBuilderExtensions.cs +++ b/src/SimpleTokenProvider/TokenProviderAppBuilderExtensions.cs @@ -4,6 +4,7 @@ using System; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; namespace SimpleTokenProvider { @@ -17,7 +18,7 @@ public static class TokenProviderAppBuilderExtensions /// The to add the middleware to. /// A that specifies options for the middleware. /// A reference to this instance after the operation has completed. - public static IApplicationBuilder UseSimpleTokenProvider(this IApplicationBuilder app, TokenProviderOptions options) + public static IApplicationBuilder UseSimpleTokenProvider(this IApplicationBuilder app, TokenProviderOptions options, TokenValidationParameters tokenValidationParameters) { if (app == null) { @@ -29,7 +30,12 @@ public static IApplicationBuilder UseSimpleTokenProvider(this IApplicationBuilde throw new ArgumentNullException(nameof(options)); } - return app.UseMiddleware(Options.Create(options)); + if (tokenValidationParameters == null) + { + throw new ArgumentNullException(nameof(tokenValidationParameters)); + } + + return app.UseMiddleware(Options.Create(options), tokenValidationParameters); } } } \ No newline at end of file diff --git a/src/SimpleTokenProvider/TokenProviderMiddleware.cs b/src/SimpleTokenProvider/TokenProviderMiddleware.cs index 506345c..17c421b 100644 --- a/src/SimpleTokenProvider/TokenProviderMiddleware.cs +++ b/src/SimpleTokenProvider/TokenProviderMiddleware.cs @@ -23,18 +23,25 @@ public class TokenProviderMiddleware { private readonly RequestDelegate _next; private readonly TokenProviderOptions _options; + private readonly TokenValidationParameters _tokenValidationParameters; private readonly ILogger _logger; private readonly JsonSerializerSettings _serializerSettings; public TokenProviderMiddleware( RequestDelegate next, IOptions options, + TokenValidationParameters tokenValidationParameters, ILoggerFactory loggerFactory) { _next = next; _logger = loggerFactory.CreateLogger(); + _tokenValidationParameters = tokenValidationParameters; _options = options.Value; + if(tokenValidationParameters == null) + { + throw new ArgumentNullException(nameof(tokenValidationParameters)); + } ThrowIfInvalidOptions(_options); _serializerSettings = new JsonSerializerSettings @@ -45,8 +52,11 @@ public TokenProviderMiddleware( public Task Invoke(HttpContext context) { + // first figure out whether this is a request for a new token or for a refresh + bool isCreate = context.Request.Path.Equals(_options.Path, StringComparison.Ordinal); + bool isRefresh = !isCreate && context.Request.Path.Equals(_options.RefreshPath, StringComparison.Ordinal); // If the request path doesn't match, skip - if (!context.Request.Path.Equals(_options.Path, StringComparison.Ordinal)) + if (!isCreate && !isRefresh) { return _next(context); } @@ -59,9 +69,48 @@ public Task Invoke(HttpContext context) return context.Response.WriteAsync("Bad request."); } - _logger.LogInformation("Handling request: " + context.Request.Path); + _logger.LogInformation($"Handling request for {(isCreate ? "create token": "refresh token")}: " + context.Request.Path); - return GenerateToken(context); + if (isCreate) + { + return GenerateToken(context); + } + else + { + return IssueRefreshedToken(context); + } + } + + private Task IssueRefreshedToken(HttpContext context) + { + try + { + // first extract token text from Authorization header + string authenticationText = context.Request.Headers["Authorization"].ToString(); + int firstSpace = authenticationText.IndexOf(" "); + string tokenText = authenticationText.Substring(firstSpace + 1); + + var jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); + SecurityToken originalToken; + // validate token using validation parameters + var claimsi = jwtSecurityTokenHandler.ValidateToken(tokenText, _tokenValidationParameters, out originalToken); + var now = DateTime.UtcNow; + // create a new token based on original one + // apply new expiration + var jwt = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: ((JwtSecurityToken)originalToken).Claims, + notBefore: now, + expires: now.Add(_options.Expiration), + signingCredentials: _options.SigningCredentials); + return WriteTokenResponse(context, jwt); + } + catch + { + context.Response.StatusCode = 400; + return context.Response.WriteAsync("Bad request or invalid token."); + } } private async Task GenerateToken(HttpContext context) @@ -96,6 +145,11 @@ private async Task GenerateToken(HttpContext context) notBefore: now, expires: now.Add(_options.Expiration), signingCredentials: _options.SigningCredentials); + await WriteTokenResponse(context, jwt); + } + + private async Task WriteTokenResponse(HttpContext context, JwtSecurityToken jwt) + { var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt); var response = new diff --git a/src/SimpleTokenProvider/TokenProviderOptions.cs b/src/SimpleTokenProvider/TokenProviderOptions.cs index d4bc631..eb636e6 100644 --- a/src/SimpleTokenProvider/TokenProviderOptions.cs +++ b/src/SimpleTokenProvider/TokenProviderOptions.cs @@ -19,6 +19,11 @@ public class TokenProviderOptions /// /// The default path is /token. public string Path { get; set; } = "/token"; + /// + /// The relative path for refresh token. + /// + /// The default path is /refresh-token + public string RefreshPath { get; set; } = "/refresh-token"; /// /// The Issuer (iss) claim for generated tokens. diff --git a/test/SimpleTokenProvider.Test/Startup.Auth.cs b/test/SimpleTokenProvider.Test/Startup.Auth.cs index 4169025..3fb5d77 100644 --- a/test/SimpleTokenProvider.Test/Startup.Auth.cs +++ b/test/SimpleTokenProvider.Test/Startup.Auth.cs @@ -17,16 +17,6 @@ public partial class Startup private void ConfigureAuth(IApplicationBuilder app) { var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(secretKey)); - - app.UseSimpleTokenProvider(new TokenProviderOptions - { - Path = "/api/token", - Audience = "ExampleAudience", - Issuer = "ExampleIssuer", - SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256), - IdentityResolver = GetIdentity - }); - var tokenValidationParameters = new TokenValidationParameters { // The signing key must match! @@ -43,11 +33,21 @@ private void ConfigureAuth(IApplicationBuilder app) // Validate the token expiry ValidateLifetime = true, - + // If you want to allow a certain amount of clock drift, set that here: ClockSkew = TimeSpan.Zero }; + app.UseSimpleTokenProvider(new TokenProviderOptions + { + Path = "/api/token", + RefreshPath = "/api/refresh-token", + Audience = "ExampleAudience", + Issuer = "ExampleIssuer", + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256), + IdentityResolver = GetIdentity + }, tokenValidationParameters); + app.UseJwtBearerAuthentication(new JwtBearerOptions { AutomaticAuthenticate = true,