I'm working on an SPA web app, and im using IdentityServer4 code flow to handle the authorization. So I have the following components:
- The Angular Client Side App,
https://localhost:5001 - Asp.net 3 Web Api project, running on
https://localhost:5001 - A native mobile application consuming the API, with JWT authentication, running on
http://localhost:8100
Rightnow im trying to authenticate the mobile app users, however the user interaction login screen keeps redirecting, and im getting login_required.
Tracing back the calls, this is what im getting:
- the mobile app calls the
/connect/authorizeendpoint in a webview - Check - I'm redirected to my SPA app login path
https://localhost:5001/auth/login?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fredirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8100%252Fauth%252Fcallback%26client_id%3Dcharla-mobile%26response_type%3Dcode%26state%3Dpq1nokeuVj%26scope%3Dcharla-api%2520openid%2520profile%2520offline_access%26code_challenge%3DJxDVsm2YnMAbvOuemWWXjYLLt-Mi1TpHoO7zhDkCWSI%26code_challenge_method%3DS256- Check - I enter the username/password and call the
loginendpoint in theAccountController.cs- Check - im redirected to the IdentityServer callback handler
https://localhost:5001/auth/login?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fredirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8100%252Fauth%252Fcallback%26client_id%3Dcharla-mobile%26response_type%3Dcode%26state%3Dpq1nokeuVj%26scope%3Dcharla-api%2520openid%2520profile%2520offline_access%26code_challenge%3DJxDVsm2YnMAbvOuemWWXjYLLt-Mi1TpHoO7zhDkCWSI%26code_challenge_method%3DS256- Check - Instead of being redirected to the redirect_uri with the authorization code, im redirected again to
https://localhost:5001/auth/login?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fredirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8100%252Fauth%252Fcallback%26client_id%3Dcharla-mobile%26response_type%3Dcode%26state%3Dpbx8alT61z%26scope%3Dcharla-api%2520openid%2520profile%2520offline_access%26code_challenge%3DrGappKbnVpUNzlNHst4t5RlHephWFfJTVXuwtpQ8tZI%26code_challenge_method%3DS256- Problem here IdentityServer can't sense that the user is now signed in. I keep getting thelogin_requirederror in the debug terminal.
So here is my setup:
Startup.cs
namespace Charla
{
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.AddIdentityCore<ApplicationUser>(options => { });
new IdentityBuilder(typeof(ApplicationUser), typeof(IdentityRole), services)
.AddRoleManager<RoleManager<IdentityRole>>()
.AddSignInManager<SignInManager<ApplicationUser>>()
.AddEntityFrameworkStores<ConverseContext>();
/*services.AddIdentity<ApplicationUser, IdentityRole>()
.AddRoleManager<RoleManager<IdentityRole>>()
.AddSignInManager<SignInManager<ApplicationUser>>()
.AddEntityFrameworkStores<ConverseContext>()
.AddDefaultTokenProviders();*/
var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
var builder = services
.AddIdentityServer(SetupIdentityServer)
.AddDeveloperSigningCredential()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b => b.UseMySql(Configuration.GetConnectionString("DefaultConnection"),
sqloptions => {
sqloptions.ServerVersion(new Version(10, 1, 37), ServerType.MariaDb); // replace with your Server Version and Type
sqloptions.MigrationsAssembly(migrationsAssembly);
});
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b => b.UseMySql(Configuration.GetConnectionString("DefaultConnection"),
sqloptions => {
sqloptions.ServerVersion(new Version(10, 1, 37), ServerType.MariaDb); // replace with your Server Version and Type
sqloptions.MigrationsAssembly(migrationsAssembly);
});
});
/*services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication(options => {
options.Authority = Configuration.GetValue<string>("IdentityServer:Jwt:Authority");
options.RequireHttpsMetadata = false;
options.ApiName = "charla-api";
});*/
services.AddAuthentication(opt => {
opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(jwt =>{
jwt.Authority = Configuration.GetValue<string>("IdentityServer:Jwt:Authority");
jwt.RequireHttpsMetadata = false;
jwt.TokenValidationParameters.ValidateAudience = false;
jwt.TokenValidationParameters.ValidTypes = new[] { "at+jwt" };
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider serviceProvider)
{
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSpaStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseIdentityServer();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
//endpoints.MapRazorPages();
endpoints.MapHub<ChatHub>("/hub");
});
}
private static void SetupIdentityServer(IdentityServerOptions options)
{
options.UserInteraction.LoginUrl = "/auth/login";
options.UserInteraction.LoginReturnUrlParameter = "returnUrl";
options.UserInteraction.LogoutUrl = "/logout";
options.UserInteraction.ErrorUrl= "/error/identity";
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
//options.EmitStaticAudienceClaim = true;
//identityServerOptions.Authentication.CookieLifetime = TimeSpan.FromDays(1);
}
}
}
}
AccountController.cs
[Route("api/[controller]")]
[ApiController]
[AllowAnonymous]
public class AccountController : ControllerBase
{
[HttpPost("login")]
public async Task<IActionResult> Login(UserResource model) {
var result = await signInManager.PasswordSignInAsync(model.email, model.password, isPersistent: true, lockoutOnFailure: false);
var context = await interaction.GetAuthorizationContextAsync(model.return_url);
if (result.Succeeded) {
var uo = db.Users.Include(q => q.UserOrganization).Single( q => q.Email == model.email ).UserOrganization.First();
uo.LastLogin = DateTime.UtcNow;
await db.SaveChangesAsync();
// let identity server know that we loggedin
await identityEvents.RaiseAsync(new UserLoginSuccessEvent(
model.email, uo.UserId, model.email, clientId: context?.Client.ClientId
));
//return Redirect(model.return_url);
return Ok( new{
email = model.email,
return_url = context.RedirectUri
} );
}
await identityEvents.RaiseAsync(new UserLoginFailureEvent(model.email, "invalid credentials", clientId:context?.Client.ClientId));
return NotFound(new {});
}
IdentityConfig.cs - I'm using EF tables, but it was seeded from the below:
using IdentityServer4;
using IdentityServer4.Models;
using System.Collections.Generic;
namespace Charla
{
public static class IdentityConfig
{
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static IEnumerable<ApiResource> GetApiResources()
{
return new List<ApiResource>
{
new ApiResource("charla-api", "Charla API Resource")
};
}
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("charla-api", "Charla API Scope")
};
public static IEnumerable<Client> Clients =>
new Client[]
{
// charla web app
new Client
{
ClientId = "charla-spa",
ClientName = "Charla Web App",
RequireClientSecret = false,
AllowOfflineAccess = true,
AllowAccessTokensViaBrowser = true,
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = {
"https://localhost:5001/authentication/login-callback",
"https://app-dev.getcharla.com/authentication/login-callback",
"https://app.getcharla.com/authentication/login-callback"
},
//FrontChannelLogoutUri = "https://localhost:5001/authentication/logout-callback",
PostLogoutRedirectUris = {
"https://localhost:5001/authentication/logout-callback",
"https://app-dev.getcharla.com/authentication/logout-callback",
"https://app.getcharla.com/authentication/logout-callback"
},
AllowedScopes = {
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
"charla-api"
}
},
// mobile client
new Client
{
ClientId = "charla-mobile",
ClientName = "Charla Mobile Apps",
RequireClientSecret = false,
AllowedGrantTypes = GrantTypes.Code,
AllowAccessTokensViaBrowser = true,
AllowOfflineAccess = true,
RedirectUris = {
"https://getcharla.com/ios_redirect",
"http://localhost:8100/auth/callback"
},
PostLogoutRedirectUris = {
"https://getcharla.com/ios_redirect_endsession",
"http://localhost:8100/auth/endsession"
},
AllowedScopes = {
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
"charla-api"
},
AllowedCorsOrigins = { "http://localhost:8100", "https://localhost:5001", "http://localhost:5000" }
},
};
}
}
login.component.ts - The function in the web component that fires the call to the login endpoint and that is doing the redirection:
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
this.returnUrl = params['returnUrl'] || '/';
});
}
/**
* Form Submit
*/
submit() {
const controls = this.loginForm.controls;
const authData = {
email: controls['email'].value,
password: controls['password'].value
};
this.auth
.login(authData.email, authData.password, this.returnUrl)
.pipe(
finalize(() => {
this.loading = false;
this.cdr.markForCheck();
})
)
.subscribe(
result => {
window.location = this.returnUrl;
this.router.navigateByUrl(this.returnUrl); // Main page
},
err => {
console.log(err);
if (( 'status' in err) && ( err.status === 404)){
this.authNoticeService.setNotice(this.translate.instant('AUTH.VALIDATION.INVALID_LOGIN'), 'danger');
}
}
);
}
Have no clue why its not working. I read I should be using HttpContent.SignInAsync in the login web api endpoint, but im already using var result = await signInManager.PasswordSignInAsync(model.email, model.password, isPersistent: true, lockoutOnFailure: false); so I assume thats enough.
Other choices I made I'm not sure are right, like using AddIdentityCore instead of AddIdentity. Should I be adding AddAspNetIdentity<ApplicationUser>()?
