This post shows how to implement localization in IdentityServer4 when using the Implicit Flow with an Angular client.
Code: https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow
The problem
When the oidc implicit client calls the endpoint /connect/authorize to authenticate and authorize the client and the identity, the user is redirected to the AccountController login method using the IdentityServer4 package. If the culture and the ui-culture is set using the query string or using the default localization filter, it gets ignored in the host. By using a localization cookie, which is set from the client SPA application, it is possible to use this culture in IdentityServer4 and it’s host.
Part 2 IdentityServer4 Localization using ui_locales and the query string
IdentityServer 4 Localization
The ASP.NET Core localization is configured in the startup method of the IdentityServer4 host. The localization service, the resource paths and the RequestCultureProviders are configured here. A custom LocalizationCookieProvider is added to handle the localization cookie. The MVC middleware is then configured to use the localization.
public void ConfigureServices(IServiceCollection services) { ... services.AddSingleton<LocService>(); services.AddLocalization(options => options.ResourcesPath = "Resources"); services.AddAuthentication(); services.AddIdentity<ApplicationUser, IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>(); services.Configure<RequestLocalizationOptions>( options => { var supportedCultures = new List<CultureInfo> { new CultureInfo("en-US"), new CultureInfo("de-CH"), new CultureInfo("fr-CH"), new CultureInfo("it-CH") }; options.DefaultRequestCulture = new RequestCulture(culture: "de-CH", uiCulture: "de-CH"); options.SupportedCultures = supportedCultures; options.SupportedUICultures = supportedCultures; options.RequestCultureProviders.Clear(); var provider = new LocalizationCookieProvider { CookieName = "defaultLocale" }; options.RequestCultureProviders.Insert(0, provider); }); services.AddMvc() .AddViewLocalization() .AddDataAnnotationsLocalization(options => { options.DataAnnotationLocalizerProvider = (type, factory) => { var assemblyName = new AssemblyName(typeof(SharedResource).GetTypeInfo().Assembly.FullName); return factory.Create("SharedResource", assemblyName.Name); }; }); ... services.AddIdentityServer() .AddSigningCredential(cert) .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddInMemoryApiResources(Config.GetApiResources()) .AddInMemoryClients(Config.GetClients()) .AddAspNetIdentity<ApplicationUser>() .AddProfileService<IdentityWithAdditionalClaimsProfileService>(); }
The localization is added to the pipe in the Configure method.
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { ... var locOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>(); app.UseRequestLocalization(locOptions.Value); app.UseStaticFiles(); app.UseIdentityServer(); app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); }
The LocalizationCookieProvider class implements the RequestCultureProvider to handle the localization sent from the Angular client as a cookie. The class uses the defaultLocale cookie to set the culture. This was configured in the startup class previously.
using Microsoft.AspNetCore.Localization; using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; namespace IdentityServerWithIdentitySQLite { public class LocalizationCookieProvider : RequestCultureProvider { public static readonly string DefaultCookieName = ".AspNetCore.Culture"; public string CookieName { get; set; } = DefaultCookieName; /// <inheritdoc /> public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext) { if (httpContext == null) { throw new ArgumentNullException(nameof(httpContext)); } var cookie = httpContext.Request.Cookies[CookieName]; if (string.IsNullOrEmpty(cookie)) { return NullProviderCultureResult; } var providerResultCulture = ParseCookieValue(cookie); return Task.FromResult(providerResultCulture); } public static ProviderCultureResult ParseCookieValue(string value) { if (string.IsNullOrWhiteSpace(value)) { return null; } var cultureName = value; var uiCultureName = value; if (cultureName == null && uiCultureName == null) { // No values specified for either so no match return null; } if (cultureName != null && uiCultureName == null) { uiCultureName = cultureName; } if (cultureName == null && uiCultureName != null) { cultureName = uiCultureName; } return new ProviderCultureResult(cultureName, uiCultureName); } } }
The Account login view uses the localization to translate the different texts into one of the supported cultures.
@using System.Globalization @using IdentityServerWithAspNetIdentity.Resources @model IdentityServer4.Quickstart.UI.Models.LoginViewModel @inject SignInManager<ApplicationUser> SignInManager @inject LocService SharedLocalizer @{ ViewData["Title"] = @SharedLocalizer.GetLocalizedHtmlString("login"); } <h2>@ViewData["Title"]</h2> <div class="row"> <div class="col-md-8"> <section> <form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model.ReturnUrl" method="post" class="form-horizontal"> <h4>@CultureInfo.CurrentCulture</h4> <hr /> <div asp-validation-summary="All" class="text-danger"></div> <div class="form-group"> <label class="col-md-4 control-label">@SharedLocalizer.GetLocalizedHtmlString("email")</label> <div class="col-md-8"> <input asp-for="Email" class="form-control" /> <span asp-validation-for="Email" class="text-danger"></span> </div> </div> <div class="form-group"> <label class="col-md-4 control-label">@SharedLocalizer.GetLocalizedHtmlString("password")</label> <div class="col-md-8"> <input asp-for="Password" class="form-control" type="password" /> <span asp-validation-for="Password" class="text-danger"></span> </div> </div> <div class="form-group"> <label class="col-md-4 control-label">@SharedLocalizer.GetLocalizedHtmlString("rememberMe")</label> <div class="checkbox col-md-8"> <input asp-for="RememberLogin" /> </div> </div> <div class="form-group"> <div class="col-md-offset-4 col-md-8"> <button type="submit" class="btn btn-default">@SharedLocalizer.GetLocalizedHtmlString("login")</button> </div> </div> <p> <a asp-action="Register" asp-route-returnurl="@Model.ReturnUrl">@SharedLocalizer.GetLocalizedHtmlString("registerAsNewUser")</a> </p> <p> <a asp-action="ForgotPassword">@SharedLocalizer.GetLocalizedHtmlString("forgotYourPassword")</a> </p> </form> </section> </div> </div> @section Scripts { @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } }
The LocService uses the IStringLocalizerFactory interface to configure a shared resource for the resources.
using Microsoft.Extensions.Localization; using System.Reflection; namespace IdentityServerWithAspNetIdentity.Resources { public class LocService { private readonly IStringLocalizer _localizer; public LocService(IStringLocalizerFactory factory) { var type = typeof(SharedResource); var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName); _localizer = factory.Create("SharedResource", assemblyName.Name); } public LocalizedString GetLocalizedHtmlString(string key) { return _localizer[key]; } } }
Client Localization
The Angular SPA client uses the angular-l10n the localize the application.
"dependencies": { "angular-l10n": "^4.0.0",
the angular-l10n is configured in the app module and is configured to save the current culture in a cookie called defaultLocale. This cookie matches what was configured on the server.
... import { L10nConfig, L10nLoader, TranslationModule, StorageStrategy, ProviderType } from 'angular-l10n'; const l10nConfig: L10nConfig = { locale: { languages: [ { code: 'en', dir: 'ltr' }, { code: 'it', dir: 'ltr' }, { code: 'fr', dir: 'ltr' }, { code: 'de', dir: 'ltr' } ], language: 'en', storage: StorageStrategy.Cookie }, translation: { providers: [ { type: ProviderType.Static, prefix: './i18n/locale-' } ], caching: true, missingValue: 'No key' } }; @NgModule({ imports: [ BrowserModule, FormsModule, routing, HttpClientModule, TranslationModule.forRoot(l10nConfig), DataEventRecordsModule, AuthModule.forRoot(), ], declarations: [ AppComponent, ForbiddenComponent, HomeComponent, UnauthorizedComponent, SecureFilesComponent ], providers: [ OidcSecurityService, SecureFileService, Configuration ], bootstrap: [AppComponent], }) export class AppModule { clientConfiguration: any; constructor( public oidcSecurityService: OidcSecurityService, private http: HttpClient, configuration: Configuration, public l10nLoader: L10nLoader ) { this.l10nLoader.load(); console.log('APP STARTING'); this.configClient().subscribe((config: any) => { this.clientConfiguration = config; let openIDImplicitFlowConfiguration = new OpenIDImplicitFlowConfiguration(); openIDImplicitFlowConfiguration.stsServer = this.clientConfiguration.stsServer; openIDImplicitFlowConfiguration.redirect_url = this.clientConfiguration.redirect_url; // The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience. // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client. openIDImplicitFlowConfiguration.client_id = this.clientConfiguration.client_id; openIDImplicitFlowConfiguration.response_type = this.clientConfiguration.response_type; openIDImplicitFlowConfiguration.scope = this.clientConfiguration.scope; openIDImplicitFlowConfiguration.post_logout_redirect_uri = this.clientConfiguration.post_logout_redirect_uri; openIDImplicitFlowConfiguration.start_checksession = this.clientConfiguration.start_checksession; openIDImplicitFlowConfiguration.silent_renew = this.clientConfiguration.silent_renew; openIDImplicitFlowConfiguration.post_login_route = this.clientConfiguration.startup_route; // HTTP 403 openIDImplicitFlowConfiguration.forbidden_route = this.clientConfiguration.forbidden_route; // HTTP 401 openIDImplicitFlowConfiguration.unauthorized_route = this.clientConfiguration.unauthorized_route; openIDImplicitFlowConfiguration.log_console_warning_active = this.clientConfiguration.log_console_warning_active; openIDImplicitFlowConfiguration.log_console_debug_active = this.clientConfiguration.log_console_debug_active; // id_token C8: The iat Claim can be used to reject tokens that were issued too far away from the current time, // limiting the amount of time that nonces need to be stored to prevent attacks.The acceptable range is Client specific. openIDImplicitFlowConfiguration.max_id_token_iat_offset_allowed_in_seconds = this.clientConfiguration.max_id_token_iat_offset_allowed_in_seconds; configuration.FileServer = this.clientConfiguration.apiFileServer; configuration.Server = this.clientConfiguration.apiServer; this.oidcSecurityService.setupModule(openIDImplicitFlowConfiguration); // if you need custom parameters // this.oidcSecurityService.setCustomRequestParameters({ 'culture': 'fr-CH', 'ui-culture': 'fr-CH', 'ui_locales': 'fr-CH' }); }); } configClient() { console.log('window.location', window.location); console.log('window.location.href', window.location.href); console.log('window.location.origin', window.location.origin); console.log(`${window.location.origin}/api/ClientAppSettings`); return this.http.get(`${window.location.origin}/api/ClientAppSettings`); } }
When the applications are started, the user can select a culture and login.
And the login view is localized correctly in de-CH
Or in french, if the culture is fr-CH
Links:
https://damienbod.com/2017/11/11/identityserver4-localization-using-ui_locales-and-the-query-string/
https://damienbod.com/2017/11/01/shared-localization-in-asp-net-core-mvc/
https://github.com/IdentityServer/IdentityServer4
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization