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
[…] IdentityServer4 Localization with the OIDC Implicit Flow – Damien Bowden […]
[…] IdentityServer4 Localization with the OIDC Implicit Flow (Damien Bowden) […]
> 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.
Are you saying that the SPA is setting a cookie and IdentityServer reads it? This implies that the SPA and IdentityServer live on the same domain?
yes, which means you have the domain restrictions which apply to cookies.
Well – then this is not realistic. You have to try harder 😉
What’s wrong with reading the `ui_locales` parameter from the account/login action – dropping a cookie – and then from there leverage the localization features?
For most of my applications, the STS, API and UI are hosted in the sub domains of a domain where I can share the cookie.
Using the ui_locales, I can access the value in the AccountController login after the redirect from IdentityServer4 and set the culture, but it gets ignored in the views.
PS This works without problem when the ASP.NET Core aplication does not use IdentityServer4 middleware, but I haven’t found the reason why.
Set the culture cookie in the account controller is what I am saying..
Tried this, and it didn’t work.
CultureInfo.CurrentCulture = new CultureInfo(culture);
CultureInfo.CurrentUICulture = new CultureInfo(culture);
Well – you should have enough experience to know that this *should* work. So somewhere there is a problem/bug waiting to be fixed.
Yes, It’s probably something small but I have not found a solution/reason. As soon as I find one, I will switch to the ui_locales but at present, I have not found a way to get this working or the reason why.
Here’s the issue for this:
https://github.com/IdentityServer/IdentityServer4/issues/1703
If the problem is elsewhere, I will update, close, or fix it.
PS I have to research this in my free time, and my time for OSS is limited at the moment.
[…] post is part 2 from the previous post IdentityServer4 Localization with the OIDC Implicit Flow where the localization was implemented using a shared cookie between the applications. This has its […]