Send SignIn request to Idp- Sustainsys Saml2 - owin

I want to send sign in request for IdP for every unauthenticated request to my Web Application. I am using Sustainsys.Saml2.Owin. I am using the default StubIdp.
In the configuration method, I have added a filter which checks if incoming request is authenticated. If it is not, I issue an OWIN challenge.
My problem is that, the OWIN challenge is not redirecting the application to StubIdp login page. What am I missing here?
Following is the code for Startup.Auth.cs class
public void Configuration(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.CreatePerOwinContext(ApplicationDbContext.Create);
app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);
app.CreatePerOwinContext<ApplicationSignInManager>(ApplicationSignInManager.Create);
// Enable the application to use a cookie to store information for the signed in user
// and to use a cookie to temporarily store information about a user logging in with a third party login provider
// Configure the sign in cookie
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
CookieName = "MyJobSizeToken",
CookieSecure = CookieSecureOption.Always,
CookieHttpOnly = true,
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/Account/Login"),
Provider = new CookieAuthenticationProvider
{
// Enables the application to validate the security stamp when the user logs in.
// This is a security feature which is used when you change a password or add an external login to your account.
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(30),
regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
}
});
app.UseSaml2Authentication(CreateSaml2Options());
app.Use((context, next) =>
{
if (!context.Authentication.User.Identity.IsAuthenticated)
{
//context.Environment.Add("saml2.idp", new EntityId("https://stubidp.sustainsys.com/Metadata"));
//context.Authentication.Challenge("https://stubidp.sustainsys.com/Metadata");
HttpContext.Current.GetOwinContext().Authentication.Challenge(new
AuthenticationProperties { RedirectUri = "/" },
"KentorAuthServices"); // This line does not redirect to the stubidp
}
return next.Invoke();
});
}
private static Saml2AuthenticationOptions CreateSaml2Options()
{
var spOptions = CreateSPOptions();
var Saml2Options = new Saml2AuthenticationOptions(false)
{
SPOptions = spOptions
};
var idp = new IdentityProvider(new EntityId("https://stubidp.sustainsys.com/Metadata"), spOptions)
{
AllowUnsolicitedAuthnResponse = true,
Binding = Saml2BindingType.HttpRedirect,
SingleSignOnServiceUrl = new Uri("https://stubidp.sustainsys.com")
};
idp.SigningKeys.AddConfiguredKey(
new X509Certificate2(
HostingEnvironment.MapPath(
"~/App_Data/stubidp.sustainsys.com.cer")));
Saml2Options.IdentityProviders.Add(idp);
// It's enough to just create the federation and associate it
// with the options. The federation will load the metadata and
// update the options with any identity providers found.
new Federation("http://localhost:52071/Federation", true, Saml2Options);
return Saml2Options;
}
private static SPOptions CreateSPOptions()
{
var nz = CultureInfo.GetCultureInfo("en-nz");
var organization = new Organization();
organization.Names.Add(new LocalizedName("Flink Solutions", nz));
organization.DisplayNames.Add(new LocalizedName("Flink Solutions", nz));
organization.Urls.Add(new LocalizedUri(new Uri("http://www.Sustainsys.se"), nz));
var spOptions = new SPOptions
{
EntityId = new EntityId("https://localhost:44339/Saml2"),
ReturnUrl = new Uri("http://localhost:44339/"),
DiscoveryServiceUrl = new Uri("http://localhost:44339/DiscoveryService"),
Organization = organization
};
var techContact = new ContactPerson
{
Type = ContactType.Technical
};
techContact.EmailAddresses.Add("Saml2#example.com");
spOptions.Contacts.Add(techContact);
var supportContact = new ContactPerson
{
Type = ContactType.Support
};
supportContact.EmailAddresses.Add("support#example.com");
spOptions.Contacts.Add(supportContact);
var attributeConsumingService = new AttributeConsumingService("Saml2")
{
IsDefault = true,
};
attributeConsumingService.RequestedAttributes.Add(
new RequestedAttribute("urn:someName")
{
FriendlyName = "Some Name",
IsRequired = true,
NameFormat = RequestedAttribute.AttributeNameFormatUri
});
attributeConsumingService.RequestedAttributes.Add(
new RequestedAttribute("Minimal"));
spOptions.AttributeConsumingServices.Add(attributeConsumingService);
spOptions.ServiceCertificates.Add(new X509Certificate2(
AppDomain.CurrentDomain.SetupInformation.ApplicationBase + "/App_Data/Sustainsys.Saml2.Tests.pfx"));
return spOptions;
}
If I issue the request from a cshtml page like;
<script type="text/javascript">
window.location.href = "/Saml2/Signin?idp=https://stubidp.sustainsys.com/Metadata";
</script>
then it works. Why can't I issue the challenge from the OWIN filter?

The second param to Challenge() must be the authentication scheme as set in the Saml2 options. The default is now Saml2, so replace your KentorAuthServices scheme to Saml2.

Related

Issue with IdentityServer Front Channel Logout when deployed to Azure App Service

I'm have an issue with IdentityServer Front Channel Logout when deploying to Azure App Service. I have three applications (Idp and two SP's) that I have configured to use Front Channel Logout as follows:
IdP Client Configurations:
public static IEnumerable<Client> Clients =>
new Client[]
{
new Client
{
ClientId = "Authorization.Service.UI.DEV",
ClientName = "Authorization Service UI [Development]",
AllowedGrantTypes = GrantTypes.Code,
RequireClientSecret = false,
RequirePkce = true,
AllowOfflineAccess = true,
// where to redirect to after login
RedirectUris = new List<string>
{
"https://localhost:44305/signin-oidc",
},
// where to redirect to after logout
PostLogoutRedirectUris = new List<string>
{
"https://localhost:44305/signout-callback-oidc",
},
FrontChannelLogoutUri = "https://localhost:44305/Account/FrontChannelLogout",
FrontChannelLogoutSessionRequired = true,
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Email,
"Authorization.Service.API.Read",
"Authorization.Service.API.Write"
},
AccessTokenLifetime = Convert.ToInt32((new TimeSpan(1,0,0,0)).TotalSeconds)
},
new Client
{
ClientId = "Authorization.Service.UI",
ClientName = "Authorization Service UI",
AllowedGrantTypes = GrantTypes.Code,
RequireClientSecret = false,
RequirePkce = true,
AllowOfflineAccess = true,
// where to redirect to after login
RedirectUris = new List<string>
{
"https://as-ui-cdcavell.azurewebsites.net/signin-oidc"
},
// where to redirect to after logout
PostLogoutRedirectUris = new List<string>
{
"https://as-ui-cdcavell.azurewebsites.net/signout-callback-oidc"
},
FrontChannelLogoutUri = "https://as-ui-cdcavell.azurewebsites.net/Account/FrontChannelLogout",
FrontChannelLogoutSessionRequired = true,
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Email,
"Authorization.Service.API.Read",
"Authorization.Service.API.Write"
},
AccessTokenLifetime = Convert.ToInt32((new TimeSpan(1,0,0,0)).TotalSeconds)
},
new Client
{
ClientId = "cdcavell.name.DEV",
ClientName = "Personal Website of Christopher D. Cavell [Development]",
AllowedGrantTypes = GrantTypes.Code,
RequireClientSecret = false,
RequirePkce = true,
AllowOfflineAccess = true,
// where to redirect to after login
RedirectUris = new List<string>
{
"https://localhost:44349/signin-oidc",
},
// where to redirect to after logout
PostLogoutRedirectUris = new List<string>
{
"https://localhost:44349/signout-callback-oidc",
},
FrontChannelLogoutSessionRequired = true,
FrontChannelLogoutUri = "https://localhost:44349/Account/FrontChannelLogout",
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Email,
"Authorization.Service.API.Read"
},
AlwaysIncludeUserClaimsInIdToken = true,
AccessTokenLifetime = Convert.ToInt32((new TimeSpan(1,0,0,0)).TotalSeconds)
},
new Client
{
ClientId = "cdcavell.name",
ClientName = "Personal Website of Christopher D. Cavell",
AllowedGrantTypes = GrantTypes.Code,
RequireClientSecret = false,
RequirePkce = true,
AllowOfflineAccess = true,
// where to redirect to after login
RedirectUris = new List<string>
{
"https://cdcavell.name/signin-oidc"
},
// where to redirect to after logout
PostLogoutRedirectUris = new List<string>
{
"https://cdcavell.name/signout-callback-oidc"
},
FrontChannelLogoutSessionRequired = true,
FrontChannelLogoutUri = "https://cdcavell.name/Account/FrontChannelLogout",
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Email,
"Authorization.Service.API.Read"
},
AlwaysIncludeUserClaimsInIdToken = true,
AccessTokenLifetime = Convert.ToInt32((new TimeSpan(1,0,0,0)).TotalSeconds)
}
};
SP Logout Actions:
/// <summary>
/// Logout method
/// </summary>
/// <returns>Task<IActionResult></returns>
/// <method>Logout()</method>
[AllowAnonymous]
[HttpGet]
public async Task<IActionResult> Logout()
{
if (User.Identity.IsAuthenticated)
{
// Remove Authorization record
Data.Authorization authorization = Data.Authorization.GetRecord(User.Claims, _dbContext);
authorization.Delete(_dbContext);
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
SignOut(CookieAuthenticationDefaults.AuthenticationScheme, "oidc");
DiscoveryCache discoveryCache = (DiscoveryCache)HttpContext
.RequestServices.GetService(typeof(IDiscoveryCache));
DiscoveryDocumentResponse discovery = discoveryCache.GetAsync().Result;
if (!discovery.IsError)
return Redirect(discovery.EndSessionEndpoint);
}
return RedirectToAction("Index", "Home");
}
/// <summary>
/// Front Channel SLO Logout method
/// <br /><br />
/// https://andersonnjen.com/2019/03/22/identityserver4-global-logout/
/// </summary>
/// <returns>Task<IActionResult></returns>
/// <method>FrontChannelLogout(string sid)</method>
[AllowAnonymous]
[HttpGet]
public async Task<IActionResult> FrontChannelLogout(string sid)
{
if (User.Identity.IsAuthenticated)
{
var currentSid = User.FindFirst("sid")?.Value ?? "";
if (string.Equals(currentSid, sid, StringComparison.Ordinal))
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
return NoContent();
}
In testing on localhost running on different ports the FrontChannelLogout action is called in both SP's and both are signed out of IdP. When code is deployed to Azure App Services, only the SP that initiated signout is signed out. The second SP still remains signed in to IdP.
I was thinking this was something to do with Content Security Policy but still get same result after configuring CSP as follows:
IdP Content Security Policy:
csp += "frame-ancestors 'self' https://cdcavell.name https://as-ui-cdcavell.azurewebsites.net; ";
csp += "frame-src 'self'; ";
SP Content Security Policy:
csp += "frame-ancestors 'self'; ";
csp += "frame-src 'self' https://dis5-cdcavell.azurewebsites.net https://www.google.com; ";
Wanting to know if anyone has experienced this or if it might be something to to with Azue App Service configuration?
Full source at: https://github.com/cdcavell/cdcavell.name
Website at: https://cdcavell.name
Update:
I have tried several things such as adding the fix for samesite = none issue
// Override the CookieAuthenticationOptions for DefaultCookieAuthenticationScheme
// https://github.com/IdentityServer/IdentityServer4/blob/c30de032ec1dedc3b17dfa342043850638e84b43/src/IdentityServer4/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs#L28
services.Configure<CookieAuthenticationOptions>(IdentityServerConstants.DefaultCookieAuthenticationScheme, options =>
{
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.IsEssential = true;
});
as well as disabling the Arr Session Affinity cookie
<httpProtocol>
<customHeaders>
<add name="Arr-Disable-Session-Affinity" value="true"/>
</customHeaders>
</httpProtocol>
neither of these worked so I'm abandoning FrontChanelLogout and will try and implement BackChanelLogout utilizing Redis Cache as outlined by damienbod's article
When you call signout, you should not return your own result or view back.
Instead the action method should look like this:
public async Task DoLogout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
}
This is because SignOutAsync creates its own response and when you return a response you overwrite this internal response.
Issue resolved:
It was due to mixed domains (cdcavell.name and azurewebsites.net). One app service was under cdcavell.name and the other two were under azurewebsites.net.
Setup custom domains in Azure App Service and then added a wild card SSL binding.

Authentication problem - secure web application with API auth server

I have started to create a software architecture where i have:
Auth_API - as an auth server
Resource_API - as a resource API (protected with Auth_API)
WebApplication (mvc) - a frontend application (protected with Auth_API).
Based on https://bitoftech.net/2014/06/01/token-based-authentication-asp-net-web-api-2-owin-asp-net-identity/ article I have successfully made a Google authentication.
WebApp == redirects to ==> Auth_API == challenge ==> Google ==> API receives externalAccessToken, registers user locally and returns localAccessToken
Now everything is OK when I would want to use bearer authorization (using local access token).
But I also want to sign in to by ASP MVC application with (cookie?) ClaimsIdentity.
I was thinking about switching to JWT, but I am not sure which way I should go...
Bit of code:
Auth_API - obtain local access token
/// <summary>
/// Returns local access token for already registered users
/// </summary>
/// <param name="provider"></param>
/// <param name="externalAccessToken"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet]
[Route("ObtainLocalAccessToken")]
public async Task<IHttpActionResult> ObtainLocalAccessToken(string provider, string externalAccessToken)
{
if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(externalAccessToken))
{
return BadRequest("Provider or external access token is not sent");
}
var verifiedAccessToken = await VerifyExternalAccessToken(provider, externalAccessToken);
if (verifiedAccessToken == null)
{
return BadRequest("Invalid Provider or External Access Token");
}
IdentityUser user = await _repo.FindAsync(new UserLoginInfo(provider, verifiedAccessToken.user_id));
bool hasRegistered = user != null;
if (!hasRegistered)
{
return BadRequest("External user is not registered");
}
//generate access token response
var accessTokenResponse = GenerateLocalAccessTokenResponse(user.UserName);
return Ok(accessTokenResponse);
}
Generate Local Access Token algorythm
private JObject GenerateLocalAccessTokenResponse(string userName)
{
var tokenExpiration = TimeSpan.FromDays(1);
ClaimsIdentity identity = new ClaimsIdentity(OAuthDefaults.AuthenticationType);
identity.AddClaim(new Claim(ClaimTypes.Name, userName));
identity.AddClaim(new Claim("role", "user"));
var props = new AuthenticationProperties()
{
IssuedUtc = DateTime.UtcNow,
ExpiresUtc = DateTime.UtcNow.Add(tokenExpiration),
};
var ticket = new AuthenticationTicket(identity, props);
var accessToken = Startup.OAuthBearerOptions.AccessTokenFormat.Protect(ticket);
JObject tokenResponse = new JObject(
new JProperty("userName", userName),
new JProperty("access_token", accessToken),
new JProperty("token_type", "bearer"),
new JProperty("expires_in", tokenExpiration.TotalSeconds.ToString()),
new JProperty(".issued", ticket.Properties.IssuedUtc.ToString()),
new JProperty(".expires", ticket.Properties.ExpiresUtc.ToString())
);
return tokenResponse;
}
Web application - part of login action:
if (hasLocalAccount)
{
var client = new RestClient(baseApiUrl);
var externalLoginUrl = "Account/ObtainLocalAccessToken";
var externalLoginRequest = new RestRequest(externalLoginUrl, Method.GET);
externalLoginRequest.AddQueryParameter("provider", provider);
externalLoginRequest.AddQueryParameter("externalAccessToken", externalAccessToken);
var externalLoginResponse = client.Execute(externalLoginRequest);
if (externalLoginResponse.IsSuccessful)
{
JObject response = JObject.Parse(externalLoginResponse.Content);
string localAccessToken = response["access_token"].Value<string>();
string localTokenExpiresIn = response["expires_in"].Value<string>();
// WHAT TO DO WHERE TO SIGN IN A USER ???
//AuthenticationTicket ticket = Startup.OAuthBearerOptions.AccessTokenFormat.Unprotect(localAccessToken); <== this returns NULL
return RedirectToAction("Index", "Home");
}
}

Aspnetcore Correlation failed when facebook or google login and Invalid token when confirm email

I've done migration of my app to aspnetcore and now I do have random issue with validation tokens.
1. Issue is that randomly users receive
An error was encountered while handling the remote login. Correlation
failed.
The problem is that if I go and test it my self it works.
Second problem is that when user receive Email confirmation token and click link from email they will get
invalid token
so they can't confirm email.
Firstly I thought issue is with UseCookiePolicy but I've disabled it.
Startup.cs
namespace Flymark.Online.Web
{
public class Startup
{
private readonly IHostingEnvironment _env;
public Startup(IHostingEnvironment env)
{
_env = env;
var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", true, true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", true)
.AddEnvironmentVariables();
Configuration = builder.Build();
}
public IConfigurationRoot Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Configure SnapshotCollector from application settings
services.Configure<SnapshotCollectorConfiguration>(
Configuration.GetSection(nameof(SnapshotCollectorConfiguration)));
// Add SnapshotCollector telemetry processor.
services.AddSingleton<ITelemetryProcessorFactory>(sp => new SnapshotCollectorTelemetryProcessorFactory(sp));
services.AddApplicationInsightsTelemetryProcessor<TelemetryFilter>();
services.AddSingleton<ITelemetryInitializer, AppInsightsInitializer>();
services.AddCors();
var decompressionOptions = new RequestDecompressionOptions();
decompressionOptions.UseDefaults();
services.AddRequestDecompression(decompressionOptions);
FlymarkAppSettings.Init(Configuration, _env.EnvironmentName);
var storageUri = new Uri(Configuration.GetValue<string>("Flymark:DataProtectionStorageUrl"));
//Get a reference to a container to use for the sample code, and create it if it does not exist.
var container = new CloudBlobClient(storageUri).GetContainerReference("data-protection");
services.AddDataProtection()
.SetApplicationName("Flymark.Online")
.PersistKeysToAzureBlobStorage(container, "data-protection.xml");
services.AddDetection();
services.AddAutoMapper();
services.AddWebMarkupMin(
options =>
{
options.AllowMinificationInDevelopmentEnvironment = true;
options.AllowCompressionInDevelopmentEnvironment = true;
})
.AddHtmlMinification(o =>
{
o.ExcludedPages = new List<IUrlMatcher>
{
new WildcardUrlMatcher("/scripts/*")
};
o.MinificationSettings.AttributeQuotesRemovalMode = HtmlAttributeQuotesRemovalMode.KeepQuotes;
o.MinificationSettings.EmptyTagRenderMode = HtmlEmptyTagRenderMode.NoSlash;
o.MinificationSettings.RemoveOptionalEndTags = false;
})
.AddXmlMinification()
.AddHttpCompression();
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.Lax;
});
services
.AddScoped<UserStore<ApplicationUser, IdentityRole<int>, FlymarkContext, int, IdentityUserClaim<int>,
IdentityUserRole<int>, IdentityUserLogin<int>, IdentityUserToken<int>, IdentityRoleClaim<int>>,
ApplicationUserStore>();
services.AddScoped<UserManager<ApplicationUser>, FlymarkUserManager>();
services.AddScoped<RoleManager<IdentityRole<int>>, ApplicationRoleManager>();
services.AddScoped<SignInManager<ApplicationUser>, ApplicationSignInManager>();
services
.AddScoped<RoleStore<IdentityRole<int>, FlymarkContext, int, IdentityUserRole<int>,
IdentityRoleClaim<int>>, ApplicationRoleStore>();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddIdentity<ApplicationUser, IdentityRole<int>>(
o =>
{
o.User.RequireUniqueEmail = true;
})
.AddUserStore<ApplicationUserStore>()
.AddUserManager<FlymarkUserManager>()
.AddRoleStore<ApplicationRoleStore>()
.AddRoleManager<ApplicationRoleManager>()
.AddSignInManager<ApplicationSignInManager>()
.AddClaimsPrincipalFactory<FlymarkClaimsPrincipalFactory>()
.AddDefaultTokenProviders();
services.AddSingleton<ILoggerFactory, LoggerFactory>(sp =>
new LoggerFactory(
sp.GetRequiredService<IEnumerable<ILoggerProvider>>(),
sp.GetRequiredService<IOptionsMonitor<LoggerFilterOptions>>()
)
);
services.Configure<ApiBehaviorOptions>(options => { options.SuppressModelStateInvalidFilter = true; });
services.AddMemoryCache();
services.AddSingleton<IEmailSender, FlymarkEmailSender>();
services.AddMvc(o =>
{
o.Conventions.Add(new FlymarkAsyncConvention());
o.AllowValidatingTopLevelNodes = false;
o.AllowEmptyInputInBodyModelBinding = true;
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
.AddJsonOptions(opt =>
{
opt.SerializerSettings.DateFormatString = "dd/MM/yyyy";
opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
var resolver = opt.SerializerSettings.ContractResolver;
if (resolver == null) return;
if (resolver is DefaultContractResolver res) res.NamingStrategy = null;
});
services.Configure<IdentityOptions>(options =>
{
// Default Password settings.
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 6;
options.Password.RequiredUniqueChars = 1;
options.Lockout.MaxFailedAccessAttempts = 20;
});
services
.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(OAuthValidationDefaults.AuthenticationScheme,
IdentityConstants.ApplicationScheme)
.RequireAuthenticatedUser()
.Build();
});
services.AddAuthentication()
.AddExternalAuthProviders(Configuration)
.AddFlymarkOpenIdConnectServer()
.AddOAuthValidation(OAuthValidationDefaults.AuthenticationScheme);
services.Configure<SecurityStampValidatorOptions>(options =>
{
// This is the key to control how often validation takes place
options.ValidationInterval = TimeSpan.FromMinutes(15);
});
services.ConfigureApplicationCookie(config =>
{
config.LoginPath = "/Identity/Account/LogIn";
config.AccessDeniedPath = "/Identity/Account/LogIn";
config.SlidingExpiration = true;
config.Events.OnRedirectToLogin = OnRedirectToLoginAsync;
});
}
private Task OnRedirectToLoginAsync(RedirectContext<CookieAuthenticationOptions> context)
{
if (context.HttpContext.Request.Path.Value.Contains("/api"))
context.Response.StatusCode = 401;
else
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
}
public void ConfigureContainer(ContainerBuilder builder)
{
builder.RegisterSource(new AnyConcreteTypeNotAlreadyRegisteredSource());
//builder.RegisterApiControllers(Assembly.GetExecutingAssembly());
builder.RegisterModule(new FlymarkDalDiModule
{
Configuration = Configuration
});
builder.RegisterModule(new DbDiModule(FlymarkAppSettings.Instance.DbContextConnection,
FlymarkAppSettings.Instance.StorageConnectionString));
builder.RegisterModule<FlymarkWebDiModule>();
}
private CultureInfo CreateCulture(string key)
{
return new CultureInfo(key)
{
NumberFormat = {NumberDecimalSeparator = "."},
DateTimeFormat = {ShortDatePattern = "dd/MM/yyyy"}
};
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerFactory, IMapper mapper)
{
#if DEBUG
mapper.ConfigurationProvider.AssertConfigurationIsValid();
#endif
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context =>
{
context.Context.Response.Headers.Add("Cache-Control", "no-cache, no-store");
context.Context.Response.Headers.Add("Expires", "-1");
}
});
}
else
{
app.UseExceptionHandler("/Error/Error500");
app.UseStaticFiles();
}
app.UseCors(builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(5))
.AllowAnyHeader();
});
app.UseRequestDecompression();
app.UseLegacyTokenContentTypeFixMiddleware();
var supportedCultures = new[]
{
CreateCulture("en"),
CreateCulture("ru"),
CreateCulture("uk")
};
app.UseFlymarkExceptionMiddleware();
app.UseCookiePolicy();
app
.UseAuthentication()
.UseDomainMiddleware()
.UseRequestLocalization(new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
})
.UseWebMarkupMin();
app.Use(async (ctx, next) =>
{
await next();
if (ctx.Response.StatusCode == 404 && !ctx.Response.HasStarted)
{
//Re-execute the request so the user gets the error page
var originalPath = ctx.Request.Path.Value;
ctx.Items["originalPath"] = originalPath;
ctx.Request.Path = "/error/error404";
await next();
}
});
app
.UseMvc(routes =>
{
routes.MapRoute(
"areaRoute",
"{area:exists}/{controller=Dashboard}/{action=Index}/{id?}");
routes.MapRoute(
"default",
"{controller=Home}/{action=Index}/{id?}");
});
}
}
}
I am generating url for email confirmation like this:
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = Url.Page("/Account/ConfirmEmail",
null,
new {userId = user.Id, code = code.ToBase64String()},
returnDomainUrl.Scheme,
returnDomainUrl.Host);
I also thought that it could be an angularjs (I still have it on my page) but its not loaded at /signin-facebook since its handled by middleware.
I think issue is somehwere with dataprotection since I am getting them in login and confirmation email
I also tried to base 64 email token, but it wont help in addition I think that url is encoded automatically by Page.Url
Finally after weeks of investigations I found an issue.
When user register I will send email and sms, then user goes and confirm sms, which will trigger update of security stamp. Then later if user click confirm email and it fails because security stamp is not the same as in a token
So moving sending confirmation email after phone number is confirmed. Solved half of my problem.
Most probably the tokens validation failed because the tokens are generated in a domain and being validated in another domain.
In ASP.Net this can be solved by having the same machineKey in both domains web.config files.
For ASP.Net Core you can replace the machineKey as described here so you have the same cryptographic settings in both of domains.
see: Replace the ASP.NET machineKey in ASP.NET Core

Re-authorize persistent login (MVC client) without triggering login UI

I am trying to figure out how a server-side client (MVC / ASP.NET Core 2) can query IdentityServer4 to retrieve various claims scopes for a persistent login created in some previous session without prompting for login if the persistent login is invalid (user inactive, cookie expired, etc).
We're using Implicit flow with third-party auth (Google, FB, etc) but we changed the session-duration on the cookie to a more user-friendly 30 day expiration in IdentityServer's ExternalLoginCallback.
Accessing claims on HttpContext.User (we are not using ASP.NET Identity) works great during the session that establishes login. On some later session, navigating to a client resource with an [Authorize] attribute also works: if the user had logged in previously, they transparently gain access to the resource, claims are populated, etc. If not, they're prompted for login, which is ok in response to a user-initiated action.
However, we have a requirement for the client landing page to alter the content depending on whether the user is anonymous or authenticated. A simple example would be "Register" and "Log in" links for anonymous users, but "Account" and "Log out" links for authenticated users.
Hence the reason to retrieve claims and jump-start the persistent login if it's valid, but do nothing (no login prompt) if invalid: we don't want the landing page to force every anonymous user to a login screen.
Nothing special to say about our setups on either end of the pipeline. Client:
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = "https://localhost:5000";
options.RequireHttpsMetadata = true;
options.ClientId = "example.com.webserver";
options.ClientSecret = "examplesecret";
options.ResponseType = "id_token";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("example.com.identity");
});
IdentityServer client resource definition:
new Client
{
ClientId = "example.com.webserver",
ClientName = "example.com",
ClientUri = "https://localhost:5002",
AllowedGrantTypes = GrantTypes.Implicit,
ClientSecrets = {new Secret("examplesecret".Sha256())},
RequireConsent = false,
AllowRememberConsent = true,
AllowOfflineAccess = true,
RedirectUris = { "https://localhost:5002/signin-oidc"},
PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc"},
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
IdentityServerConstants.StandardScopes.Phone,
IdentityServerConstants.StandardScopes.Address,
"example.com.identity"
}
}
The client runs an intentional login (user clicks "Log in" link) like so:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Login()
{
await HttpContext.SignOutAsync("oidc");
await HttpContext.ChallengeAsync("oidc",
new AuthenticationProperties() {
RedirectUri = Url.Action("LoginCallback")
});
}
The solution is to add a second OIDC auth flow in Setup, intercept the redirect to change the Prompt option to none so that no login prompt is shown, intercept the resulting login_required error message, and trigger that flow in the landing page's PageModel OnGet handler (the client app uses RazorPages).
One caveat is the handler must set flags so that this is only attempted once, and so that it can detect whether the page is being hit for the first time, or as the return-trip from the login attempt. This is achieved by just dropping a value into the Razor TempData which is just a cookie-based bucket of name-value pairs.
Add to Setup.cs
.AddOpenIdConnect("persistent", options =>
{
options.CallbackPath = "/signin-persistent";
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.Prompt = "none";
return Task.FromResult<object>(null);
},
OnMessageReceived = context => {
if(string.Equals(context.ProtocolMessage.Error, "login_required", StringComparison.Ordinal))
{
context.HandleResponse();
context.Response.Redirect("/");
}
return Task.FromResult<object>(null);
}
};
options.SignInScheme = "Cookies";
options.Authority = "https://localhost:5000";
options.RequireHttpsMetadata = true;
options.ClientId = "example.com.webserver";
options.ClientSecret = "examplesecret";
options.ResponseType = "code";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("example.com.identity");
})
Landing page (Index.cshtml.cs)
public class IndexModel : PageModel
{
private bool PersistentLoginAttempted = false;
private const string PersistentLoginFlag = "persistent_login_attempt";
public IActionResult OnGet()
{
// Always clean up an existing flag.
bool FlagFound = false;
if(!String.IsNullOrEmpty(TempData[PersistentLoginFlag] as string))
{
FlagFound = true;
TempData.Remove(PersistentLoginFlag);
}
// Try to refresh a persistent login the first time an anonymous user hits the index page in this session
if(!User.Identity.IsAuthenticated && !PersistentLoginAttempted)
{
PersistentLoginAttempted = true;
// If there was a flag, this is the return-trip from a failed persistent login attempt.
if(!FlagFound)
{
// No flag was found. Create it, then begin the OIDC challenge flow.
TempData[PersistentLoginFlag] = PersistentLoginFlag;
return Challenge("persistent");
}
}
return Page();
}
}
why not just make the authentication cookie permanent? Here's an other way to do it ... You can then check authentication on the server against the authentication server.
on the client AddOpenIdConnect :
options.Events = new OpenIdConnectEvents {
OnRedirectToIdentityProvider = context => {
context.Properties.RedirectUri = context.Request.Path;
return Task.FromResult(0);
},
OnTicketReceived = context =>
{
context.Properties.IsPersistent = true;
context.Properties.ExpiresUtc = DateTimeOffset.UtcNow.AddDays(15);
context.Properties.AllowRefresh = true;
return Task.FromResult(0);
}
};

IdentityServer3 Refresh Token - Where to invoke the refresh

I am developing a MVC application that uses OpenID and IdentityServer3.
Background:
I am running into a issue that when the Authentication Cookie times out, I need to use the refresh token to generate a new one.
I am able to login and receive the AuthorizationCodeReceived notification, which i use to request an authorization code and retrieve a RefreshToken which I store in the claims of the AuthenticationTicket.
I have tried adding logic to check and refresh the authentication in:
CookieAuthenticationProvider.OnValidateIdentity -- This works to
refresh, and I was able to update the cookie, but it is not called after the cookie expired.
Adding code in the begining of the the ResourceAuthorizationManager.CheckAccessAsync -- this does not work because the identity is null and I cannot retrieve the refresh token claim.
Adding a filter Filter for MVC, but I am unable to figure out what to add as a HttpResponseMessage for WebAPI.
public const string RefreshTokenKey = "refresh_token";
public const string ExpiresAtKey = "expires_at";
private const string AccessTokenKey = "access_token";
private static bool CheckAndRefreshTokenIfRequired(ClaimsIdentity id, out ClaimsIdentity identity)
{
if (id == null)
{
identity = null;
return false;
}
if (id.Claims.All(x => x.Type != ExpiresAtKey) || id.Claims.All(x => x.Type != RefreshTokenKey))
{
identity = id;
return false;
}
//Check if the access token has expired
var expiresAt = DateTime.Parse(id.FindFirstValue(ExpiresAtKey));
if ((expiresAt - DateTime.Now.ToLocalTime()).TotalSeconds < 0)
{
var client = GetClient();
var refreshToken = id.FindFirstValue(RefreshTokenKey);
var tokenResponse = client.RequestRefreshTokenAsync(refreshToken).Result;
if (tokenResponse.IsError)
{
throw new Exception(tokenResponse.Error);
}
var result = from c in id.Claims
where c.Type != AccessTokenKey &&
c.Type != RefreshTokenKey &&
c.Type != ExpiresAtKey
select c;
var claims = result.ToList();
claims.Add(new Claim(AccessTokenKey, tokenResponse.AccessToken));
claims.Add(new Claim(ExpiresAtKey, DateTime.Now.AddSeconds(tokenResponse.ExpiresIn).ToLocalTime().ToString()));
claims.Add(new Claim(RefreshTokenKey, tokenResponse.RefreshToken));
identity = new ClaimsIdentity(claims, id.AuthenticationType);
return true;
}
identity = id;
return false;
}
Links:
How would I use RefreshTokenHandler?
Identity Server3 documentation
Looked at the two examples, but using resourceowner flow for openid doesn't seem the right way. The MVC code flow relies on the User still having the principle, but my claims are all empty in the resource authorize.
EDIT:
Okay, so if I set the AuthenticationTicket.Properties.ExpiresUtc to null in AuthorizationCodeReceived, it is setting it to null then somewhere down the line it is setting it to 30 days instead of 5 minutes (I searched the katana and identity server source code but could not find where it is setting this value), which I can live with, but would prefer it to be the same as the browser where it is "Session"
app.UseCookieAuthentication(new CookieAuthenticationOptions()
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
CookieManager = new SystemWebChunkingCookieManager(),
Provider = new CookieAuthenticationProvider()
{
OnValidateIdentity = context =>
{
ClaimsIdentity i;
if (CheckAndRefreshTokenIfRequired(context.Identity, out i))
{
context.ReplaceIdentity(i);
}
return Task.FromResult(0);
}
}
});
The problem was that in the AuthorizationCodeRecieved notification I was passing the Properties from the original ticket, which had the timeout set for Expires for the authorization code Changing the the code to pass null in resolved the issue and allowed the CookieAuthenticationHandler.ApplyResponseGrantAsync to pass its own properties.
var claimsIdentity = new ClaimsIdentity(id.Claims, n.AuthenticationTicket.Identity.AuthenticationType, "name", "role");
n.AuthenticationTicket = new AuthenticationTicket(claimsIdentity, null);

Resources