public void Configuration(IAppBuilder app) { // Show PII information in log entries (in real app this setting should come from your app configuration source) // when you set this to true, you will see more details about an exception, like if the signature validation fails // you will see the information what key identifier was used to validate the signature of a token (otherwise you see message about PII info removed) // PII means: personally identifiable information Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; // clear the default mappings so that framework doesn't try to automatically map claims for us using its defaults JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); // Antiforgery requires this mapping/information or otherwise we will get the following error for example when trying to access visitor groups // A claim of type 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' or 'http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider' was not present on the provided ClaimsIdentity. // We could add those claim(s) to the user or directly configure the AntiForgeryConfig.UniqueClaimTypeIdentifier // here we set that antiforgery should use the 'CustomClaimNames.EpiUsername' claim value which is unique for each user in this demo AntiForgeryConfig.UniqueClaimTypeIdentifier = CustomClaimNames.EpiUsername; // this could be also JwtClaimTypes.Subject but then you need to remember to add that claim to the claimsidentity // this is not required but if you have issues with cookies then add Nuget package: Kentor.OwinCookieSaver // to see if that sorts out your cookie issue // NuGet : https://www.nuget.org/packages/Kentor.OwinCookieSaver/ // GitHub : https://github.com/Sustainsys/owin-cookie-saver //app.UseKentorOwinCookieSaver(); // set the default authentication type to 'Cookies' app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType); // set the cookie auth options // the cookies is valid for 'configuration value here' minutes and uses sliding expiration, meaning framework will extend the validty automatically // see: https://docs.microsoft.com/en-us/dotnet/api/system.web.security.formsauthentication.slidingexpiration?view=netframework-4.7.2 // the OpenIdConnectAuthenticationOptions.UseTokenLifetime has to be false for this to work, if the value is true then the token lifetime will be used which is usually very short time app.UseCookieAuthentication(new CookieAuthenticationOptions { ExpireTimeSpan = TimeSpan.FromMinutes(OIDCInMemoryConfiguration.AuthCookieValidMinutes), SlidingExpiration = true }); // Configure the OIDC auth options app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions { ClientId = OIDCInMemoryConfiguration.ClientId, ClientSecret = OIDCInMemoryConfiguration.ClientSecret, Authority = OIDCInMemoryConfiguration.Authority, // this should be set so that the middleware will use OIDC discovery to automatically setup endpoint configurations RedirectUri = OIDCInMemoryConfiguration.WebAppOidcEndpoint, // allowed URL to return tokens or authorization codes to, must match what has been defined for client in identity provider PostLogoutRedirectUri = OIDCInMemoryConfiguration.PostLogoutRedirectUrl, // allowed URL where client is allowed to be redirected after IdP logout Scope = $"openid email {CustomScopeNames.ProfileWithPermissions} {CustomScopeNames.MembershipStatus}", // TODO: add "offline_access" scope if you need refreshtoken ResponseType = "code id_token", // hybrid flow RequireHttpsMetadata = OIDCInMemoryConfiguration.RequireHttpsMetadata, TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.Name, // change name claim name to match our demo IdP returned claim name RoleClaimType = CustomClaimNames.Permission, // change role claim name to match our demo IdP returned claim name ValidateTokenReplay = true }, SignInAsAuthenticationType = CookieAuthenticationDefaults.AuthenticationType, // somewhere stated that needs to be after TokenValidationParameters UseTokenLifetime = false, // if this is true then the token life time is used (for example IdentityServer token lifetime is 5 minutes by default) for cookie auth lifetime Notifications = new OpenIdConnectAuthenticationNotifications { RedirectToIdentityProvider = notification => { // if single site setup, the redirect url is automatically set to the page you were trying to access // For example in multi-tenant setup you want to change the return url here based on the current site address // See Episerver sample: https://world.episerver.com/documentation/developer-guides/CMS/security/integrate-azure-ad-using-openid-connect/ // and the method: HandleMultiSitereturnUrl //context.ProtocolMessage.RedirectUri = "http://host-of-your-site2/the-return-page/path/here/"; // what kind of message are we processing switch (notification.ProtocolMessage.RequestType) { case OpenIdConnectRequestType.Authentication: if (notification.OwinContext.Response.StatusCode == 401) { // if the request is ajax request, like Episerver Dojo framework, don't try to redirect // but return 401 so the UI will properly display the login dialog if (IsAjaxRequest(notification.Request)) { if (Logger.IsInformationEnabled()) { Logger.Information($"Request is made with AJAX and response is 401."); } notification.HandleResponse(); return(Task.FromResult(0)); } // To avoid a redirect loop to the IdP server send 403 when user is authenticated but does not have access if (notification.OwinContext.Authentication.User.Identity.IsAuthenticated) { if (Logger.IsInformationEnabled()) { Logger.Information($"Request response code would be 401 but user '{notification.OwinContext.Authentication.User.Identity.Name}' is authenticated, switching response code to 403 (forbidden)."); } notification.OwinContext.Response.StatusCode = 403; notification.HandleResponse(); return(Task.FromResult(0)); } } break; case OpenIdConnectRequestType.Logout: // If signing out, add the id_token_hint if present // see: http://openid.net/specs/openid-connect-session-1_0.html#rfc.section.5 if (notification.OwinContext.Authentication.User.Identity.IsAuthenticated) { Logger.Information($"User is logging out. User: {notification.OwinContext.Authentication.User.Identity.Name}."); } var idTokenHint = notification.OwinContext.Authentication.User.FindFirst(OpenIdConnectParameterNames.IdToken); if (idTokenHint != null) { if (Logger.IsDebugEnabled()) { Logger.Debug($"Redirecting to Identity provider for logout with IdTokenHint."); } notification.ProtocolMessage.IdTokenHint = idTokenHint.Value; } else { if (Logger.IsDebugEnabled()) { Logger.Debug($"Redirecting to Identity provider for logout without IdTokenHint."); } } return(Task.FromResult(0)); case OpenIdConnectRequestType.Token: // nothing here :D break; default: break; } return(Task.FromResult(0)); }, AuthorizationCodeReceived = async notification => { // show info about the claims if debug logging is enabled if (Logger.IsDebugEnabled()) { Logger.Debug($"Authorization code received for sub: {notification.JwtSecurityToken.Subject}. Received claims: {GetClaimsAsString(notification.JwtSecurityToken.Claims)}."); } else { Logger.Information($"Authorization code received for sub: {notification.JwtSecurityToken.Subject}."); } // config has been automatically setup using OIDC discovery because we have set the Authority value previously in when configuring OpenIdConnectAuthenticationOptions OpenIdConnectConfiguration configuration = null; try { // get OpenIdConnectConfiguration configuration = await notification.Options.ConfigurationManager.GetConfigurationAsync(notification.Request.CallCancelled); } catch (Exception ex) { Logger.Error($"Failed to get OpenIdConnectConfiguration. Cannot authorize the client with sub: {notification.JwtSecurityToken.Subject}.", ex); throw; } // configure token client, endpoint is automatically configured using the Auto discovery: /.well-known/openid-configuration var tokenClient = new TokenClient(configuration.TokenEndpoint, notification.Options.ClientId, notification.Options.ClientSecret, style: AuthenticationStyle.PostValues); // exchange the authorization 'code' to access token var tokenResponse = await tokenClient.RequestAuthorizationCodeAsync(notification.ProtocolMessage.Code, notification.RedirectUri, cancellationToken: notification.Request.CallCancelled); // check if there was an error fetching the acces token if (tokenResponse.IsError) { Logger.Error($"There was an error retrieving the access token for sub: {notification.JwtSecurityToken.Subject}. Error: {tokenResponse.Error}. Error description: {tokenResponse.ErrorDescription}."); notification.HandleResponse(); // TODO : redirect to friendly error page // does 302 redirect, but SEO is not your concern here // notification.Response.Redirect("/your/nice/error/page/address/here/"); notification.Response.Write($"Error retrieving access token. {tokenResponse.ErrorDescription}."); return; } if (string.IsNullOrWhiteSpace(tokenResponse.AccessToken)) { Logger.Error($"Didn't receive access token for sub: {notification.JwtSecurityToken.Subject}."); notification.HandleResponse(); // TODO : redirect to friendly error page // does 302 redirect, but SEO is not your concern here // notification.Response.Redirect("/your/nice/error/page/address/here/"); notification.Response.Write($"Error, access token not received. {tokenResponse.ErrorDescription}."); return; } // sub and iss claims musta have same value when using hybrid flow (in the id token and tokenresponse) // http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken2 3.3.3.6 ID Token // If an ID Token is returned from both the Authorization Endpoint and from the Token Endpoint, // which is the case for the response_type values "code id_token" and "code id_token token", the iss and sub Claim Values MUST be identical in both ID Tokens. if (!string.IsNullOrWhiteSpace(tokenResponse.IdentityToken)) { try { JwtSecurityTokenHandler idTokenHandler = new JwtSecurityTokenHandler(); var parsedIdToken = idTokenHandler.ReadJwtToken(tokenResponse.IdentityToken); if (string.Compare(parsedIdToken.Issuer, notification.JwtSecurityToken.Issuer, StringComparison.OrdinalIgnoreCase) != 0 || string.Compare(parsedIdToken.Subject, notification.JwtSecurityToken.Subject, StringComparison.OrdinalIgnoreCase) != 0) { Logger.Error($"Authorization endpoint id token 'sub' ({notification.JwtSecurityToken.Subject}) and 'iss' ({notification.JwtSecurityToken.Issuer}) claim values don't match with token endpoint 'sub' ({parsedIdToken.Subject}) and 'iss' ({parsedIdToken.Issuer}) claim values."); notification.HandleResponse(); // TODO : redirect to friendly error page // does 302 redirect, but SEO is not your concern here // notification.Response.Redirect("/your/nice/error/page/address/here/"); notification.Response.Write("Token endpoint identity token doesn't match autohorization endpoint returned identity token."); return; } } catch (Exception ex) { Logger.Error($"Failed to validate token endpoint identity token against autohorization endpoint returned identity token.", ex); notification.HandleResponse(); // TODO : redirect to friendly error page // does 302 redirect, but SEO is not your concern here // notification.Response.Redirect("/your/nice/error/page/address/here/"); notification.Response.Write("Failed to validate token endpoint identity token against autohorization endpoint returned identity token."); return; } } else { Logger.Information($"Token endpoint didn't return identity token for sub: {notification.JwtSecurityToken.Subject}."); } // get userinfo using the access token var userInfoClient = new UserInfoClient(configuration.UserInfoEndpoint); var userInfoResponse = await userInfoClient.GetAsync(tokenResponse.AccessToken); // check if there was an error fetching the user information if (userInfoResponse.IsError) { Logger.Error($"There was an error retrieving the user information for sub: {notification.JwtSecurityToken.Subject}. Error: {userInfoResponse.Error}."); notification.HandleResponse(); // TODO : redirect to friendly error page // does 302 redirect, but SEO is not your concern here // notification.Response.Redirect("/your/nice/error/page/address/here/"); notification.Response.Write($"Error retrieving user information. {userInfoResponse.Error}."); return; } if (Logger.IsDebugEnabled()) { Logger.Debug($"Userinfo received for sub: {notification.JwtSecurityToken.Subject}. Received claims: {GetClaimsAsString(userInfoResponse.Claims)}."); } // Create a new claims identity as the automatically created claimsidentity can contain claims that we don't need // and we can keep the authentication cookie size smaller this way // NOTE: Episerver uses the ClaimsIdentity name claim as the username (this will be also the display name when logged in), so make sure it is unique! // Other claims synched automatically are (defined in class EPiServer.Security.ClaimTypeOptions, EPiServer.Framework): // Email claim: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress // GivenName claim: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname // Surname claim: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname // create new claimsidentity and set name claim name to JwtClaimTypes.PreferredUserName and role claim to JwtClaimTypes.Role // NOTE!: The RP MUST NOT rely upon this value being unique, as discussed in http://openid.net/specs/openid-connect-basic-1_0-32.html#ClaimStability // as Episerver uses the Name claim as the username (unique) and also as the display name in edit view, we could use the sub claim but this could be a guid // so for the sake of the demo I will use the JwtClaimTypes.PreferredUserName even though it is said that it can't be tusted to be unique and can contani special characters // in real world cases you just need to know what claims are unique and displayable for the end user, email might be one option but then you need to check that you get it always // in this demo I will always return the same username in the JwtClaimTypes.PreferredUserName as was used to login to the IdP var authClaimsIdentity = new ClaimsIdentity(notification.AuthenticationTicket.Identity.AuthenticationType, CustomClaimNames.EpiUsername, JwtClaimTypes.Role); // split claims to two sets: role claims and other claims List <Claim> roleClaims = new List <Claim>(); List <Claim> otherClaims = new List <Claim>(); foreach (var c in userInfoResponse.Claims) { if (string.Compare(c.Type, CustomClaimNames.Permission, StringComparison.OrdinalIgnoreCase) == 0) { roleClaims.Add(c); } else { otherClaims.Add(c); } } // get the preferred username claim and if it doesn't exist use sub claim value string username = otherClaims.GetClaimValue(JwtClaimTypes.PreferredUserName) ?? notification.JwtSecurityToken.Subject; authClaimsIdentity.AddClaim(new Claim(CustomClaimNames.EpiUsername, username)); // should we add the claim to allow publishing of content bool addPublisherClaim = false; // is the user admin var adminUserClaim = roleClaims.Find(c => string.Compare(c.Value, PermissionGroupNames.WebSiteSuperUser, StringComparison.OrdinalIgnoreCase) == 0); if (adminUserClaim != null) { authClaimsIdentity.AddClaim(new Claim(JwtClaimTypes.Role, EpiRoles.Admin)); addPublisherClaim = true; } // you could have a claim for users from previous CMS and then you could have a custom mapping from that claim to new claim(s) used by Episerver //var oldEditorClaim = roleClaims.Find(c => string.Compare(c.Value, "BOGUS_OLD_CMS_USER", StringComparison.OrdinalIgnoreCase) == 0); //if (oldEditorClaim != null) //{ // authClaimsIdentity.AddClaim(new Claim(JwtClaimTypes.Role, "WebEditors")); // addPublisherClaim = true; //} // is the user editor var editUserClaim = roleClaims.Find(c => string.Compare(c.Value, PermissionGroupNames.WebSiteEditor, StringComparison.OrdinalIgnoreCase) == 0); if (editUserClaim != null) { authClaimsIdentity.AddClaim(new Claim(JwtClaimTypes.Role, EpiRoles.Editor)); addPublisherClaim = true; } // is the user a reader without edit/publishing rights var readerUserClaim = roleClaims.Find(c => string.Compare(c.Value, PermissionGroupNames.WebSiteReader, StringComparison.OrdinalIgnoreCase) == 0); if (readerUserClaim != null) { authClaimsIdentity.AddClaim(new Claim(JwtClaimTypes.Role, EpiRoles.Editor)); // user will get the WebEditors role but not the role to publish/edit // WebEditors role should only be used to grant access to the edit mode BUT not give any rights for content // that is why we have the "SitePublishers" in this demo, we'll grant full permissions for content for this group // but in real life scenario, you would have different and maybe more granular roles for different actions } // should we grant the publishing rights to the user if (addPublisherClaim) { authClaimsIdentity.AddClaim(new Claim(JwtClaimTypes.Role, EpiRoles.Publisher)); } // next get the email, givenname and surname claim values, our IdP uses JwtClaimTypes // so this is not something that you can copy to your solution, but you need to check what claims your IdP returns authClaimsIdentity.AddClaimFromSource(ClaimTypes.Email, otherClaims, JwtClaimTypes.Email); authClaimsIdentity.AddClaimFromSource(ClaimTypes.GivenName, otherClaims, JwtClaimTypes.GivenName); authClaimsIdentity.AddClaimFromSource(ClaimTypes.Surname, otherClaims, JwtClaimTypes.FamilyName); // For now we don't need the access token, besides it expires in 60 minutes and we can't refresh it as we don't request for a refreshtoken //authClaimsIdentity.AddClaim(new Claim(OpenIdConnectParameterNames.AccessToken, tokenResponse.AccessToken)); //authClaimsIdentity.AddClaim(new Claim("expires_at", DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString())); // this is needed for the logout, logout uses id_token_hint authClaimsIdentity.AddClaim(new Claim(OpenIdConnectParameterNames.IdToken, notification.ProtocolMessage.IdToken)); // replace the automatically created authenticationticket with our ticket which contains our minimal set of claims // remember to pass in the original ticket properties for the new ticket notification.AuthenticationTicket = new AuthenticationTicket(authClaimsIdentity, notification.AuthenticationTicket.Properties); Logger.Information($"Authenticated and logging in user '{GetFullName(authClaimsIdentity.Claims)}' (sub: {notification.JwtSecurityToken.Subject})."); // Sync user and the roles to EPiServer in the background // See: https://world.episerver.com/documentation/developer-guides/CMS/security/integrate-azure-ad-using-openid-connect/ // Please note that until a user with a role has logged in, you can't apply permissions to that role (as Episerver doesn't naturally know about that role) await ServiceLocator.Current.GetInstance <ISynchronizingUserService>().SynchronizeAsync(notification.AuthenticationTicket.Identity); }, AuthenticationFailed = notification => { Logger.Error($"Authentication failed: {notification.Exception.Message}"); notification.HandleResponse(); // TODO: redirect to a nice error page // does 302 redirect, but SEO is not your concern here // notification.Response.Redirect("/your/nice/error/page/address/here/"); notification.Response.Write(notification.Exception.Message); return(Task.FromResult(0)); }, SecurityTokenReceived = notification => { // purely to log "stages" for demo and debugging, in real app usually you can leave this notification un-implemented if (Logger.IsDebugEnabled()) { try { Logger.Debug($"Security token received. Code: '{notification.ProtocolMessage.Code}', IdToken: '{notification.ProtocolMessage.IdToken}'."); } catch (Exception ex) { Logger.Error($"Security token received. Failed to read Code and IdToken for debug logging.", ex); } } return(Task.FromResult(0)); }, SecurityTokenValidated = notification => { // purely to log "stages" for demo and debugging, in real app usually you can leave this notification un-implemented if (Logger.IsDebugEnabled()) { try { Logger.Debug($"Security token validated for sub: {notification.AuthenticationTicket.Identity.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Subject)?.Value}."); } catch (Exception ex) { Logger.Error($"Security token validated. Failed to read values from protocol message for debug logging.", ex); } } return(Task.FromResult(0)); }, MessageReceived = notification => { // purely to log "stages" for demo and debugging, in real app usually you can leave this notification un-implemented if (Logger.IsDebugEnabled()) { Logger.Debug($"Message received."); } return(Task.FromResult(0)); } } }); // see: https://docs.microsoft.com/en-us/aspnet/aspnet/overview/owin-and-katana/owin-middleware-in-the-iis-integrated-pipeline#stage-markers app.UseStageMarker(PipelineStage.Authenticate); // TODO: your util url path here app.Map("/util/login.aspx", map => { map.Run(ctx => { if (ctx.Authentication.User == null || !ctx.Authentication.User.Identity.IsAuthenticated) { // trigger authentication ctx.Response.StatusCode = 401; } else { ctx.Response.Redirect("/"); } return(Task.FromResult(0)); }); }); // TODO: your util url path here app.Map("/util/logout.aspx", map => { map.Run(ctx => { ctx.Authentication.SignOut(); return(Task.FromResult(0)); }); }); }