SignalR + token authorization via a remote service - asp.net-core

I'm making a SignalR-based service with a token-based authorization. The token is validated by an external API, which returns the user's ID. Then, notifications are sent to users with specific IDs.
The trouble that I can't get around is that SignalR's client code apparently sends 2 requests: one without a token (authentication fails) and the other with a token (authentication succeeds). For some reason, the first result gets cached and the user does not receive any notifications.
If I comment the checks and always return the correct ID, even if there's no token specified, the code suddenly starts working.
HubTOkenAuthenticationHandler.cs:
public class HubTokenAuthenticationHandler : AuthenticationHandler<HubTokenAuthenticationOptions>
{
public HubTokenAuthenticationHandler(
IOptionsMonitor<HubTokenAuthenticationOptions> options,
ILoggerFactory logFactory,
UrlEncoder encoder,
ISystemClock clock,
IAuthApiClient api
)
: base(options, logFactory, encoder, clock)
{
_api = api;
}
private readonly IAuthApiClient _api;
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
// uncommenting this line makes everything suddenly work
// return SuccessResult(1);
var token = GetToken();
if (string.IsNullOrEmpty(token))
return AuthenticateResult.NoResult();
var userId = await _api.GetUserIdAsync(token); // always returns 1
return SuccessResult(userId);
}
catch (Exception ex)
{
return AuthenticateResult.Fail(ex);
}
}
/// <summary>
/// Returns an identity with the specified user id.
/// </summary>
private AuthenticateResult SuccessResult(int userId)
{
var identity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.Name, userId.ToString())
}
);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
/// <summary>
/// Checks if there is a token specified.
/// </summary>
private string GetToken()
{
const string Scheme = "Bearer ";
var auth = Context.Request.Headers["Authorization"].ToString() ?? "";
return auth.StartsWith(Scheme)
? auth.Substring(Scheme.Length)
: "";
}
}
Startup.cs:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddHostedService<FakeNotificationService>();
services.AddSingleton<IAuthApiClient, FakeAuthApiClient>();
services.AddSingleton<IUserIdProvider, NameUserIdProvider>();
services.AddAuthentication(opts =>
{
opts.DefaultAuthenticateScheme = HubTokenAuthenticationDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = HubTokenAuthenticationDefaults.AuthenticationScheme;
})
.AddHubTokenAuthenticationScheme();
services.AddRouting(opts =>
{
opts.AppendTrailingSlash = false;
opts.LowercaseUrls = false;
});
services.AddSignalR(opts => opts.EnableDetailedErrors = true);
services.AddControllers();
services.AddMvc();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseAuthentication();
app.UseEndpoints(x =>
{
x.MapHub<InfoHub>("/signalr/info");
x.MapControllers();
});
}
}
FakeNotificationsService.cs (Sends a notification to user "1" every 2 seconds):
public class FakeNotificationService: IHostedService
{
public FakeNotificationService(IHubContext<InfoHub> hubContext, ILogger<FakeNotificationService> logger)
{
_hubContext = hubContext;
_logger = logger;
_cts = new CancellationTokenSource();
}
private readonly IHubContext<InfoHub> _hubContext;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts;
public Task StartAsync(CancellationToken cancellationToken)
{
// run in the background
Task.Run(async () =>
{
var id = 1;
while (!_cts.Token.IsCancellationRequested)
{
await Task.Delay(2000);
await _hubContext.Clients.Users(new[] {"1"})
.SendAsync("NewNotification", new {Id = id, Date = DateTime.Now});
_logger.LogInformation("Sent notification " + id);
id++;
}
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_cts.Cancel();
return Task.CompletedTask;
}
}
Debug.cshtml (client code):
<html>
<head>
<title>SignalRPipe Debug Page</title>
</head>
<body>
<h3>Notifications log</h3>
<textarea id="log" cols="180" rows="40"></textarea>
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/5.0.11/signalr.min.js"
integrity="sha512-LGhr8/QqE/4Ci4RqXolIPC+H9T0OSY2kWK2IkqVXfijt4aaNiI8/APVgji3XWCLbE5J0wgSg3x23LieFHVK62g=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script language="javascript">
var token = "123";
var conn = new signalR
.HubConnectionBuilder()
.withUrl('/signalr/info', { accessTokenFactory: () => token })
.configureLogging(signalR.LogLevel.Debug)
.build();
var logElem = document.getElementById('log');
var id = 1;
function log(text) {
logElem.innerHTML = text + '\n\n' + logElem.innerHTML;
}
conn.on("NewNotification", alarm => {
log(`[Notification ${id}]:\n${JSON.stringify(alarm)}`);
id++;
});
conn.start()
.then(() => log('Connection established.'))
.catch(err => log(`Connection failed:\n${err.toString()}`));
</script>
</body>
</html>
Minimal repro as a runnable project:
https://github.com/impworks/signalr-auth-problem
I tried the following, to no success:
Adding a fake authorization handler which just allows everything
Extracting the debug view to a separate project (express.js-based server)
What is that I'm missing here?

It doesn't look like you're handling the auth token coming from the query string which is required in certain cases such as a WebSocket connection from the browser.
See https://learn.microsoft.com/aspnet/core/signalr/authn-and-authz?view=aspnetcore-5.0#built-in-jwt-authentication for some info on how bearer auth should be handled.

Issue solved. As #Brennan correctly guessed, WebSockets do not support headers, so the token is passed via query string instead. We just need a little code to get the token from either source:
private string GetHeaderToken()
{
const string Scheme = "Bearer ";
var auth = Context.Request.Headers["Authorization"].ToString() ?? "";
return auth.StartsWith(Scheme)
? auth.Substring(Scheme.Length)
: null;
}
private string GetQueryToken()
{
return Context.Request.Query["access_token"];
}
And then, in HandleAuthenticateAsync:
var token = GetHeaderToken() ?? GetQueryToken();

Related

AddJwtBearer in multi-tenant application

I need to implement multi-tenant REST API on asp.net core and use the Jwt Web token for authentication.
Asp.net core docs suggest using the following code in Startup.cs ConfigureServices method:
services.AddAuthentication().AddJwtBearer("Bearer", options =>
{
options.Audience = "MyAudience";
options.Authority = "https://myauhorityserver.com";
}
The issue is that my REST API application is multi-tenant. The tenant is discovered from the URL, e.g.
https://apple.myapi.com,
https://samsung.myapi.com,
https://google.myapi.com
So each of such URLs will eventually point to the same IP, but based on the first word in the URL the app discovers tenant on using the appropriate DB connection.
Each such tenant has its own Authority URL. We use Keycloak as an identity management server, so each tenant on it has its own REALM.
So the Authority URL per tenant is something like that:
https://mykeycloack.com/auth/realms/11111111,
https://mykeycloack.com/auth/realms/22222222,
https://mykeycloack.com/auth/realms/33333333
The API application should be able to add and remove tenants dynamically, without restarting the application so, setting all the tenants in the application startup is not a good idea.
I was trying to add more schemas with more calls to AddJwtBearer, however, all the calls go to the schema "Bearer", according to options.Events.OnAuthenticationFailed event. It's not clear how to make other schemas to handle calls with Bearer token in HTTP header. Even though if it's somehow possible with a help of custom middle-ware, as I mention before providing tenant-specific configuration for Bearer token authentication in the app startup is not a solution as new tenants need to be added dynamically.
Additional info:
According to the fiddler, the Authority URL is finally combined with
/.well-known/openid-configuration
and called when the first request comes to the API endpoint marked with
[Authorize]
If the to configuration fails, the API call fails too. If the call to configuration is successful, the application is not calling it again on the next API requests.
I have found a solution and made it a Nuget Package for reusability you can take a look.
Pretty much I replace the JwtBearerTokenHandler by a DynamicBearerTokenHandler, it gives the flexibility, to resolve OpenIdConnectOptions at runtime.
I've also made another package that cares care of that for you, the only thing you need is to resolve the authority for the ongoing request, you receive the HttpContext.
Here is the package:
https://github.com/PoweredSoft/DynamicJwtBearer
How to use it.
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddMemoryCache();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddDynamicJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters.ValidateAudience = false;
})
.AddDynamicAuthorityJwtBearerResolver<ResolveAuthorityService>();
services.AddControllers();
}
}
The service
internal class ResolveAuthorityService : IDynamicJwtBearerAuthorityResolver
{
private readonly IConfiguration configuration;
public ResolveAuthorityService(IConfiguration configuration)
{
this.configuration = configuration;
}
public TimeSpan ExpirationOfConfiguration => TimeSpan.FromHours(1);
public Task<string> ResolveAuthority(HttpContext httpContext)
{
var realm = httpContext.Request.Headers["X-Tenant"].FirstOrDefault() ?? configuration["KeyCloak:MasterRealm"];
var authority = $"{configuration["KeyCloak:Endpoint"]}/realms/{realm}";
return Task.FromResult(authority);
}
}
Whats different from the original one
// before
if (_configuration == null && Options.ConfigurationManager != null)
{
_configuration = await
Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
}
// after
var currentConfiguration = await this.dynamicJwtBearerHanderConfigurationResolver.ResolveCurrentOpenIdConfiguration(Context);
How its replaced
public static AuthenticationBuilder AddDynamicJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, Action<JwtBearerOptions> action = null)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>, JwtBearerPostConfigureOptions>());
if (action != null)
return builder.AddScheme<JwtBearerOptions, DynamicJwtBearerHandler>(authenticationScheme, null, action);
return builder.AddScheme<JwtBearerOptions, DynamicJwtBearerHandler>(authenticationScheme, null, _ => { });
}
Source of the Handler
public class DynamicJwtBearerHandler : JwtBearerHandler
{
private readonly IDynamicJwtBearerHanderConfigurationResolver dynamicJwtBearerHanderConfigurationResolver;
public DynamicJwtBearerHandler(IOptionsMonitor<JwtBearerOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IDynamicJwtBearerHanderConfigurationResolver dynamicJwtBearerHanderConfigurationResolver) : base(options, logger, encoder, clock)
{
this.dynamicJwtBearerHanderConfigurationResolver = dynamicJwtBearerHanderConfigurationResolver;
}
/// <summary>
/// Searches the 'Authorization' header for a 'Bearer' token. If the 'Bearer' token is found, it is validated using <see cref="TokenValidationParameters"/> set in the options.
/// </summary>
/// <returns></returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
string token = null;
try
{
// Give application opportunity to find from a different location, adjust, or reject token
var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options);
// event can set the token
await Events.MessageReceived(messageReceivedContext);
if (messageReceivedContext.Result != null)
{
return messageReceivedContext.Result;
}
// If application retrieved token from somewhere else, use that.
token = messageReceivedContext.Token;
if (string.IsNullOrEmpty(token))
{
string authorization = Request.Headers[HeaderNames.Authorization];
// If no authorization header found, nothing to process further
if (string.IsNullOrEmpty(authorization))
{
return AuthenticateResult.NoResult();
}
if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
token = authorization.Substring("Bearer ".Length).Trim();
}
// If no token found, no further work possible
if (string.IsNullOrEmpty(token))
{
return AuthenticateResult.NoResult();
}
}
var currentConfiguration = await this.dynamicJwtBearerHanderConfigurationResolver.ResolveCurrentOpenIdConfiguration(Context);
var validationParameters = Options.TokenValidationParameters.Clone();
if (currentConfiguration != null)
{
var issuers = new[] { currentConfiguration.Issuer };
validationParameters.ValidIssuers = validationParameters.ValidIssuers?.Concat(issuers) ?? issuers;
validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(currentConfiguration.SigningKeys)
?? currentConfiguration.SigningKeys;
}
List<Exception> validationFailures = null;
SecurityToken validatedToken;
foreach (var validator in Options.SecurityTokenValidators)
{
if (validator.CanReadToken(token))
{
ClaimsPrincipal principal;
try
{
principal = validator.ValidateToken(token, validationParameters, out validatedToken);
}
catch (Exception ex)
{
Logger.TokenValidationFailed(ex);
// Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the event.
if (Options.RefreshOnIssuerKeyNotFound && Options.ConfigurationManager != null
&& ex is SecurityTokenSignatureKeyNotFoundException)
{
Options.ConfigurationManager.RequestRefresh();
}
if (validationFailures == null)
{
validationFailures = new List<Exception>(1);
}
validationFailures.Add(ex);
continue;
}
Logger.TokenValidationSucceeded();
var tokenValidatedContext = new TokenValidatedContext(Context, Scheme, Options)
{
Principal = principal,
SecurityToken = validatedToken
};
await Events.TokenValidated(tokenValidatedContext);
if (tokenValidatedContext.Result != null)
{
return tokenValidatedContext.Result;
}
if (Options.SaveToken)
{
tokenValidatedContext.Properties.StoreTokens(new[]
{
new AuthenticationToken { Name = "access_token", Value = token }
});
}
tokenValidatedContext.Success();
return tokenValidatedContext.Result;
}
}
if (validationFailures != null)
{
var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options)
{
Exception = (validationFailures.Count == 1) ? validationFailures[0] : new AggregateException(validationFailures)
};
await Events.AuthenticationFailed(authenticationFailedContext);
if (authenticationFailedContext.Result != null)
{
return authenticationFailedContext.Result;
}
return AuthenticateResult.Fail(authenticationFailedContext.Exception);
}
return AuthenticateResult.Fail("No SecurityTokenValidator available for token: " + token ?? "[null]");
}
catch (Exception ex)
{
Logger.ErrorProcessingMessage(ex);
var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options)
{
Exception = ex
};
await Events.AuthenticationFailed(authenticationFailedContext);
if (authenticationFailedContext.Result != null)
{
return authenticationFailedContext.Result;
}
throw;
}
}
}
If you're still trying to figure this out you can try Finbuckle.
https://www.finbuckle.com/MultiTenant/Docs/Authentication

ASP .NET Core webapi set cookie in middleware

I'm trying to set a cookie after the action is executed, struggling to get this working. I managed to see the cookie if I set it from a controller, but not from a middleware.
I have played with the order of the configuration and nothing.
The code sample is from a clean webapi created project, so if someone wants to play with it is simple, just create an empty webapi, add the CookieSet class and replace the Startup class with the one below (only added are the cookie policy options)
Here is my middleware
public class CookieSet
{
private readonly RequestDelegate _next;
public CookieSet(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
await _next.Invoke(context);
var cookieOptions = new CookieOptions()
{
Path = "/",
Expires = DateTimeOffset.UtcNow.AddHours(1),
IsEssential = true,
HttpOnly = false,
Secure = false,
};
context.Response.Cookies.Append("test", "cookie", cookieOptions);
}
}
I have added the p assignment and checked that the execution never gets there, on the Cookies.Append line it stops the execution, so there is something going on I can't figure it out.
And here is my Startup class
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => false;
options.MinimumSameSitePolicy = SameSiteMode.None;
options.HttpOnly = HttpOnlyPolicy.None;
options.Secure = CookieSecurePolicy.None;
// you can add more options here and they will be applied to all cookies (middleware and manually created cookies)
});
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseCookiePolicy(new CookiePolicyOptions
{
CheckConsentNeeded = c => false,
HttpOnly = HttpOnlyPolicy.None,
Secure = CookieSecurePolicy.None,
MinimumSameSitePolicy = SameSiteMode.None,
});
app.UseMiddleware<CookieSet>();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseMvc();
}
}
I have set all the options to the minimum requirements, tested with chrome and fiddler and nothing.
Ok, I'm talking to myself, but this is for the community...
Got this working after digging into the AspNetCore code.
Basically the cookie must be set on the callback OnStarting of the context response.
Here is the code of the middleware that makes the trick
public class CookieSet
{
private readonly RequestDelegate _next;
private readonly ASessionOptions _options;
private HttpContext _context;
public CookieSet(RequestDelegate next, IOptions<ASessionOptions> options)
{
_next = next;
_options = options.Value;
}
public async Task Invoke(HttpContext context)
{
_context = context;
context.Response.OnStarting(OnStartingCallBack);
await _next.Invoke(context);
}
private Task OnStartingCallBack()
{
var cookieOptions = new CookieOptions()
{
Path = "/",
Expires = DateTimeOffset.UtcNow.AddHours(1),
IsEssential = true,
HttpOnly = false,
Secure = false,
};
_context.Response.Cookies.Append("MyCookie", "TheValue", cookieOptions);
return Task.FromResult(0);
}
}
The AspNetCore team uses an internal class for that.
Checking the SessionMiddleware class, part of the code is as follows (removed a lot of things just for the sake of the answer):
public class SessionMiddleware
{
public async Task Invoke(HttpContext context)
{
// Removed code here
if (string.IsNullOrWhiteSpace(sessionKey) || sessionKey.Length != SessionKeyLength)
{
// Removed code here
var establisher = new SessionEstablisher(context, cookieValue, _options);
tryEstablishSession = establisher.TryEstablishSession;
isNewSessionKey = true;
}
// Removed code here
try
{
await _next(context);
}
// Removed code here
}
//Now the inner class
private class SessionEstablisher
{
private readonly HttpContext _context;
private readonly string _cookieValue;
private readonly SessionOptions _options;
private bool _shouldEstablishSession;
public SessionEstablisher(HttpContext context, string cookieValue, SessionOptions options)
{
_context = context;
_cookieValue = cookieValue;
_options = options;
context.Response.OnStarting(OnStartingCallback, state: this);
}
private static Task OnStartingCallback(object state)
{
var establisher = (SessionEstablisher)state;
if (establisher._shouldEstablishSession)
{
establisher.SetCookie();
}
return Task.FromResult(0);
}
private void SetCookie()
{
var cookieOptions = _options.Cookie.Build(_context);
var response = _context.Response;
response.Cookies.Append(_options.Cookie.Name, _cookieValue, cookieOptions);
var responseHeaders = response.Headers;
responseHeaders[HeaderNames.CacheControl] = "no-cache";
responseHeaders[HeaderNames.Pragma] = "no-cache";
responseHeaders[HeaderNames.Expires] = "-1";
}
// Returns true if the session has already been established, or if it still can be because the response has not been sent.
internal bool TryEstablishSession()
{
return (_shouldEstablishSession |= !_context.Response.HasStarted);
}
}
}
.NET 5
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// ........
app.Use(async (context, next) =>
{
var cookieOptions = new CookieOptions()
{
Path = "/",
Expires = DateTimeOffset.UtcNow.AddHours(1),
IsEssential = true,
HttpOnly = false,
Secure = false,
};
context.Response.Cookies.Append("MyCookie", "TheValue", cookieOptions);
await next();
});
// ........
}

SignalR Core Custom Authentication - Context.User.Identity is null after user is authenticated in /negotiate

I wrote a custom authentication for SignalR Core. One of the feature is anonymous login. It will create new user if it's first time user connect. The code work but the problem is the authentication done after /myhub/negotiate is cleared and all the claims in Context.User.Identity is cleared again and IsAuthenticated change to false when the client request /myhub/. Only after that the claims in Context.User.Identity is not cleared. I tried to return Fail if it's request to /myhub/negotiate but then the client won't send request to /myhub/ if I do that.
Any idea on how to fix or work around this? Is my implement of custom authentication correct?
Here is the code of all the class I'm using:
public class CustomAuthRequirementHandler : AuthorizationHandler<CustomAuthRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomAuthRequirement requirement)
{
string name = context.User.Claims.Where(p => p.Type == ClaimTypes.NameIdentifier).Select(p => p.Value).SingleOrDefault();
if (!context.User.Identity.IsAuthenticated)
context.Fail();
else
context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class CustomAuthRequirement : IAuthorizationRequirement
{
}
public class MyAuthenticationHandler : AuthenticationHandler<MyOptions>
{
public MyAuthenticationHandler(IOptionsMonitor<MyOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock) { }
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (Context.User.Identity != null && Context.User.Identity.IsAuthenticated) return await Task.FromResult(
AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(Options.Identity),
new AuthenticationProperties(),
this.Scheme.Name)));
//if (Request.Path != "/myhub/") return await Task.FromResult(AuthenticateResult.Fail()); // only do authentication in /myhub/
var u = CreateNewUser(); // connect to db create new user
var claims = new List<Claim>() { };
claims.Add(new Claim(ClaimTypes.Name, u.Id.ToString()));
claims.Add(new Claim(ClaimTypes.NameIdentifier, u.Id.ToString()));
Options.Identity = new ClaimsIdentity(claims, "Custom");
var user = new ClaimsPrincipal(Options.Identity);
Context.User = user;
return await Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(user, new AuthenticationProperties(), this.Scheme.Name)));
}
}
public class MyOptions : AuthenticationSchemeOptions
{
public ClaimsIdentity Identity { get; set; }
public MyOptions()
{
}
}
The configuration code in ConfigureServices
services.AddSingleton<IAuthorizationHandler, CustomAuthRequirementHandler>();
services.AddAuthorization(p =>
{
p.AddPolicy("MainPolicy", builder =>
{
builder.Requirements.Add(new CustomAuthRequirement());
builder.AuthenticationSchemes = new List<string> { "MyScheme" };
});
});
services.AddAuthentication(o =>
{
o.DefaultScheme = "MyScheme";
}).AddScheme<MyOptions, MyAuthenticationHandler>("MyScheme", "MyScheme", p => { });
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
services.AddSignalR();
Configure code
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseAuthentication();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSignalR(routes =>
{
routes.MapHub<Hubs.MainHub>("/main");
});
app.UseMvc();
}
Edit: Added client side code
#page
#{
ViewData["Title"] = "Home page";
}
<input type="button" onclick="anonLogin()" value="AnonLogin" />
<script src="~/##aspnet/signalr/dist/browser/signalr.js"></script>
<script type="text/javascript">
var connection;
function anonLogin() {
var token = "anon";
connection = new signalR.HubConnectionBuilder().withUrl("/main?anon=" + token).build();
connection.start().then(function () {
console.log("Connection ok");
console.log("Sending message....");
connection.invoke("Test").catch(function (err) {
return console.error("Error sending message: " + err.toString());
});
}).catch(function (err) {
console.log("Connection error: " + err.toString());
return console.error(err.toString());
});
}
</script>
I ended up creating a fake claims for identity just for the call to /myhub/negotiate since this call is unimportant and it just needs the authentication to success so it can go to /myhub/.
var u = new DomainUser() { Id = -1 };
var claims = new List<Claim>() { };
claims.Add(new Claim(ClaimTypes.Name, u.Id.ToString()));
claims.Add(new Claim(ClaimTypes.NameIdentifier, u.Id.ToString()));
Options.Identity = new ClaimsIdentity(claims, "Custom");
var user = new ClaimsPrincipal(Options.Identity);
Context.User = user;
return await Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(Context.User, new AuthenticationProperties(), this.Scheme.Name)));

Why [Authorize] attribute return 401 status code JWT + Asp.net Web Api?

I'm having big trouble finding issue with the JWT token authentication with asp.net web api. This is first time I am dealing with JWT & Web Api authentication & Authorization.
I have implemented the following code.
Startup.cs
public class Startup
{
public void Configuration(IAppBuilder app)
{
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=316888
ConfigureOAuthTokenGeneration(app);
ConfigureOAuthTokenConsumption(app);
}
private void ConfigureOAuthTokenGeneration(IAppBuilder app)
{
OAuthAuthorizationServerOptions OAuthServerOptions = new OAuthAuthorizationServerOptions()
{
//For Dev enviroment only (on production should be AllowInsecureHttp = false)
AllowInsecureHttp = true,
TokenEndpointPath = new PathString("/oauth/token"),
AccessTokenExpireTimeSpan = TimeSpan.FromDays(1),
Provider = new OAuthTokenProvider(),
RefreshTokenProvider = new RefreshTokenProvider(),
AccessTokenFormat = new Provider.JwtFormat("http://localhost:49860")
};
// OAuth 2.0 Bearer Access Token Generation
app.UseOAuthAuthorizationServer(OAuthServerOptions);
}
private void ConfigureOAuthTokenConsumption(IAppBuilder app)
{
var issuer = "http://localhost:49860";
string audienceId = Config.AudienceId;
byte[] audienceSecret = TextEncodings.Base64Url.Decode(Config.AudienceSecret);
// Api controllers with an [Authorize] attribute will be validated with JWT
app.UseJwtBearerAuthentication(
new JwtBearerAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Active,
AllowedAudiences = new[] { audienceId },
IssuerSecurityTokenProviders = new IIssuerSecurityTokenProvider[]
{
new SymmetricKeyIssuerSecurityTokenProvider(issuer, audienceSecret)
}
});
}
}
OAuthTokenProvider.cs
public class OAuthTokenProvider : OAuthAuthorizationServerProvider
{
public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
// validate client credentials (demo)
// should be stored securely (salted, hashed, iterated)
context.Validated();
return Task.FromResult<object>(null);
}
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
var allowedOrigin = "*";
context.OwinContext.Response.Headers.Add("Access-Control-Allow-Origin", new[] { allowedOrigin });
/***Note: Add User validation business logic here**/
if (context.UserName != context.Password)
{
context.SetError("invalid_grant", "The user name or password is incorrect.");
return;
}
var props = new AuthenticationProperties(new Dictionary<string, string>
{
{ "as:client_id", "Kaushik Thanki" }
});
ClaimsIdentity oAuthIdentity = new ClaimsIdentity("JWT");
var ticket = new AuthenticationTicket(oAuthIdentity, props);
context.Validated(ticket);
}
}
JwtFormat.cs
public class JwtFormat : ISecureDataFormat<AuthenticationTicket>
{
private readonly string _issuer = string.Empty;
public JwtFormat(string issuer)
{
_issuer = issuer;
}
public string Protect(AuthenticationTicket data)
{
if (data == null)
{
throw new ArgumentNullException("data");
}
string audienceId = Config.AudienceId;
string symmetricKeyAsBase64 = Config.AudienceSecret;
var keyByteArray = TextEncodings.Base64Url.Decode(symmetricKeyAsBase64);
var issued = data.Properties.IssuedUtc;
var expires = data.Properties.ExpiresUtc;
var token = new JwtSecurityToken(_issuer, audienceId, data.Identity.Claims, issued.Value.UtcDateTime, expires.Value.UtcDateTime);
var handler = new JwtSecurityTokenHandler();
var jwt = handler.WriteToken(token);
return jwt;
}
public AuthenticationTicket Unprotect(string protectedText)
{
throw new NotImplementedException();
}
}
RefreshTokenProvider.cs
public class RefreshTokenProvider : IAuthenticationTokenProvider
{
private static ConcurrentDictionary<string, AuthenticationTicket> _refreshTokens = new ConcurrentDictionary<string, AuthenticationTicket>();
public void Create(AuthenticationTokenCreateContext context)
{
throw new NotImplementedException();
}
public async Task CreateAsync(AuthenticationTokenCreateContext context)
{
var guid = Guid.NewGuid().ToString();
// maybe only create a handle the first time, then re-use for same client
// copy properties and set the desired lifetime of refresh token
var refreshTokenProperties = new AuthenticationProperties(context.Ticket.Properties.Dictionary)
{
IssuedUtc = context.Ticket.Properties.IssuedUtc,
ExpiresUtc = DateTime.UtcNow.AddYears(1)
};
var refreshTokenTicket = new AuthenticationTicket(context.Ticket.Identity, refreshTokenProperties);
//_refreshTokens.TryAdd(guid, context.Ticket);
_refreshTokens.TryAdd(guid, refreshTokenTicket);
// consider storing only the hash of the handle
context.SetToken(guid);
}
public void Receive(AuthenticationTokenReceiveContext context)
{
throw new NotImplementedException();
}
public async Task ReceiveAsync(AuthenticationTokenReceiveContext context)
{
AuthenticationTicket ticket;
if (_refreshTokens.TryRemove(context.Token, out ticket))
{
context.SetTicket(ticket);
}
}
}
Now Once I pass the authentication (Which I kept dummy for initial level matching same username & password) & got the token & refresh token.
When I request for method that is decorated with [Authorize] attribute, I always gets 401 status code.
I testing this method in postman following way
Any help or guidance will be really appreciated. I have invested my two days finding the solution for this but all in vain.

SignalR core not working with cookie Authentication

I cant seem to get SignalR core to work with cookie authentication. I have set up a test project that can successfully authenticate and make subsequent calls to a controller that requires authorization. So the regular authentication seems to be working.
But afterwards, when I try and connect to a hub and then trigger methods on the hub marked with Authorize the call will fail with this message: Authorization failed for user: (null)
I inserted a dummy middleware to inspect the requests as they come in. When calling connection.StartAsync() from my client (xamarin mobile app), I receive an OPTIONS request with context.User.Identity.IsAuthenticated being equal to true. Directly after that OnConnectedAsync on my hub gets called. At this point _contextAccessor.HttpContext.User.Identity.IsAuthenticated is false. What is responsible to de-authenticating my request. From the time it leaves my middleware, to the time OnConnectedAsync is called, something removes the authentication.
Any Ideas?
Sample Code:
public class MyMiddleware
{
private readonly RequestDelegate _next;
public MyMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
await this._next(context);
//At this point context.User.Identity.IsAuthenticated == true
}
}
public class TestHub: Hub
{
private readonly IHttpContextAccessor _contextAccessor;
public TestHub(IHttpContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
public override async Task OnConnectedAsync()
{
//At this point _contextAccessor.HttpContext.User.Identity.IsAuthenticated is false
await Task.FromResult(1);
}
public Task Send(string message)
{
return Clients.All.InvokeAsync("Send", message);
}
[Authorize]
public Task SendAuth(string message)
{
return Clients.All.InvokeAsync("SendAuth", message + " Authed");
}
}
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<MyContext>(options => options.UseInMemoryDatabase(databaseName: "MyDataBase1"));
services.AddIdentity<Auth, MyRole>().AddEntityFrameworkStores<MyContext>().AddDefaultTokenProviders();
services.Configure<IdentityOptions>(options => {
options.Password.RequireDigit = false;
options.Password.RequiredLength = 3;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequireLowercase = false;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
options.Lockout.MaxFailedAccessAttempts = 10;
options.User.RequireUniqueEmail = true;
});
services.AddSignalR();
services.AddTransient<TestHub>();
services.AddTransient<MyMiddleware>();
services.AddAuthentication();
services.AddAuthorization();
services.AddMvc();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseMiddleware<MyMiddleware>();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseAuthentication();
app.UseSignalR(routes =>
{
routes.MapHub<TestHub>("TestHub");
});
app.UseMvc(routes =>
{
routes.MapRoute(name: "default", template: "{controller=App}/{action=Index}/{id?}");
});
}
}
And this is the client code:
public async Task Test()
{
var cookieJar = new CookieContainer();
var handler = new HttpClientHandler
{
CookieContainer = cookieJar,
UseCookies = true,
UseDefaultCredentials = false
};
var client = new HttpClient(handler);
var json = JsonConvert.SerializeObject((new Auth { Name = "craig", Password = "12345" }));
var content = new StringContent(json, Encoding.UTF8, "application/json");
var result1 = await client.PostAsync("http://localhost:5000/api/My", content); //cookie created
var result2 = await client.PostAsync("http://localhost:5000/api/My/authtest", content); //cookie tested and works
var connection = new HubConnectionBuilder()
.WithUrl("http://localhost:5000/TestHub")
.WithConsoleLogger()
.WithMessageHandler(handler)
.Build();
connection.On<string>("Send", data =>
{
Console.WriteLine($"Received: {data}");
});
connection.On<string>("SendAuth", data =>
{
Console.WriteLine($"Received: {data}");
});
await connection.StartAsync();
await connection.InvokeAsync("Send", "Hello"); //Succeeds, no auth required
await connection.InvokeAsync("SendAuth", "Hello NEEDSAUTH"); //Fails, auth required
}
If you are using Core 2 try changing the order of UseAuthentication, place it before the UseSignalR method.
app.UseAuthentication();
app.UseSignalR...
Then inside the hub the Identity property shouldn't be null.
Context.User.Identity.Name
It looks like this is an issue in the WebSocketsTransport where we don't copy Cookies into the websocket options. We currently copy headers only. I'll file an issue to get it looked at.