In this post, I show how an Angular application could be secured using the OpenID Connect Code Flow with Proof Key for Code Exchange (PKCE).
The Angular application uses the OIDC lib angular-auth-oidc-client. In this example, the src code is used directly, but you could also use the npm package. Here’s an example which uses the npm package.
Code https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow
lib src: https://github.com/damienbod/angular-auth-oidc-client
npm package: https://www.npmjs.com/package/angular-auth-oidc-client
History
2020-05-03 Updated Angular code
Configuring the Angular client
The Angular application loads the configurations from a configuration json file. The response_type is set to “code”. This defines the OpenID Connect (OIDC) flow. PKCE is always used, as this is a public client which cannot keep a secret.
The other configurations must match the OpenID Connect client configurations on the server.
"ClientAppSettings": { "stsServer": "https://localhost:44318", "redirect_url": "https://localhost:44352", "client_id": "angular_code_client", "response_type": "code", "scope": "dataEventRecords securedFiles openid profile", "post_logout_redirect_uri": "https://localhost:44352", "start_checksession": true, "silent_renew": true, "startup_route": "/dataeventrecords", "forbidden_route": "/forbidden", "unauthorized_route": "/unauthorized", "logLevel: 0, "max_id_token_iat_offset_allowed_in_seconds": 10, }
The Angular application reads the configuration in the app.module and initializes the security lib.
import { NgModule, APP_INITIALIZER } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { AppComponent } from './app.component'; import { Configuration } from './app.constants'; import { routing } from './app.routes'; import { HttpClient, HttpClientModule } from '@angular/common/http'; import { SecureFileService } from './securefile/SecureFileService'; import { ForbiddenComponent } from './forbidden/forbidden.component'; import { HomeComponent } from './home/home.component'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; import { SecureFilesComponent } from './securefile/securefiles.component'; import { DataEventRecordsModule } from './dataeventrecords/dataeventrecords.module'; import { AuthModule, OidcConfigService } from './auth/angular-auth-oidc-client'; import { L10nConfig, L10nLoader, TranslationModule, StorageStrategy, ProviderType } from 'angular-l10n'; import { AuthorizationGuard } from './authorization.guard'; import { map, switchMap } from 'rxjs/operators'; 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' } }; export function configureAuth(oidcConfigService: OidcConfigService, httpClient: HttpClient) { const setupAction$ = httpClient.get<any>(`${window.location.origin}/api/ClientAppSettings`).pipe( map((customConfig) => { return { stsServer: customConfig.stsServer, redirectUrl: customConfig.redirect_url, clientId: customConfig.client_id, responseType: customConfig.response_type, scope: customConfig.scope, postLogoutRedirectUri: customConfig.post_logout_redirect_uri, startCheckSession: customConfig.start_checksession, silentRenew: customConfig.silent_renew, silentRenewUrl: customConfig.redirect_url + '/silent-renew.html', postLoginRoute: customConfig.startup_route, forbiddenRoute: customConfig.forbidden_route, unauthorizedRoute: customConfig.unauthorized_route, logLevel: 0, // LogLevel.Debug, // customConfig.logLevel maxIdTokenIatOffsetAllowedInSeconds: customConfig.max_id_token_iat_offset_allowed_in_seconds, historyCleanupOff: true, // autoUserinfo: false, }; }), switchMap((config) => oidcConfigService.withConfig(config)) ); return () => setupAction$.toPromise(); } @NgModule({ imports: [ BrowserModule, FormsModule, routing, HttpClientModule, TranslationModule.forRoot(l10nConfig), DataEventRecordsModule, AuthModule.forRoot(), ], declarations: [ AppComponent, ForbiddenComponent, HomeComponent, UnauthorizedComponent, SecureFilesComponent ], providers: [ OidcConfigService, { provide: APP_INITIALIZER, useFactory: configureAuth, deps: [OidcConfigService, HttpClient], multi: true, }, AuthorizationGuard, SecureFileService, Configuration ], bootstrap: [AppComponent], }) export class AppModule { constructor(public l10nLoader: L10nLoader) { this.l10nLoader.load(); console.log('APP STARTING'); } }
The redirect request with the code from the secure token server (STS) needs to be handled inside the Angular application. This is done in the app.component.
If the redirected URL from the server has the code and state parameters, and the state is valid, the tokens are requested from the STS server. The tokens in the response are validated as defined in the OIDC specification.
import { OidcClientNotification, OidcSecurityService, } from './auth/angular-auth-oidc-client'; import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { LocaleService, TranslationService, Language } from 'angular-l10n'; import './app.component.css'; @Component({ selector: 'app-component', templateUrl: 'app.component.html', }) export class AppComponent implements OnInit { @Language() lang = ''; title = ''; userDataChanged$: Observable<OidcClientNotification<any>>; userData$: Observable<any>; isAuthenticated$: Observable<boolean>; checkSessionChanged$: Observable<boolean>; checkSessionChanged: any; constructor( public oidcSecurityService: OidcSecurityService, public locale: LocaleService, public translation: TranslationService ) { console.log('AppComponent STARTING'); } ngOnInit() { this.userData$ = this.oidcSecurityService.userData$; this.isAuthenticated$ = this.oidcSecurityService.isAuthenticated$; this.checkSessionChanged$ = this.oidcSecurityService.checkSessionChanged$; this.oidcSecurityService.checkAuth().subscribe((isAuthenticated) => console.log('app authenticated', isAuthenticated)); } changeCulture(language: string, country: string) { this.locale.setDefaultLocale(language, country); console.log('set language: ' + language); } login() { console.log('start login'); let culture = 'de-CH'; if (this.locale.getCurrentCountry()) { culture = this.locale.getCurrentLanguage() + '-' + this.locale.getCurrentCountry(); } console.log(culture); this.oidcSecurityService.authorize({ customParams: { 'ui_locales': culture } }); } refreshSession() { console.log('start refreshSession'); this.oidcSecurityService.authorize(); } logout() { console.log('start logoff'); this.oidcSecurityService.logoff(); } }
IdentityServer4 is used to configure and implement the secure token server. The client is configured to use PKCE and no secret. The client ID must match the Angular application configuration.
new Client { ClientName = "angular_code_client", ClientId = "angular_code_client", AccessTokenType = AccessTokenType.Reference, // RequireConsent = false, AccessTokenLifetime = 330,// 330 seconds, default 60 minutes IdentityTokenLifetime = 30, RequireClientSecret = false, AllowedGrantTypes = GrantTypes.Code, RequirePkce = true, AllowAccessTokensViaBrowser = true, RedirectUris = new List<string> { "https://localhost:44352", "https://localhost:44352/silent-renew.html" }, PostLogoutRedirectUris = new List<string> { "https://localhost:44352/unauthorized", "https://localhost:44352" }, AllowedCorsOrigins = new List<string> { "https://localhost:44352" }, AllowedScopes = new List<string> { "openid", "dataEventRecords", "dataeventrecordsscope", "securedFiles", "securedfilesscope", "role", "profile", "email" } },
Silent Renew
The tokens are refreshed using an iframe like the OpenID Connect Implicit Flow. The HTML has one small difference. The detail value of the event returned to the Angular application returns the URL and not just the hash.
<!doctype html> <html> <head> <base href="./"> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>silent-renew</title> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> </head> <body> <script> window.onload = function () { /* The parent window hosts the Angular application */ var parent = window.parent; /* Send the id_token information to the oidc message handler */ var event = new CustomEvent('oidc-silent-renew-message', { detail: window.location }); parent.dispatchEvent(event); }; </script> </body> </html>
Now the OIDC Flow can be used in the Angular client application. When the application is started, the configurations are loaded.
The authorize request is sent to the STS with the code_challenge and the code_challenge_method.
https://localhost:44318/connect/authorize? client_id=angular_code_client &redirect_uri=https%3A%2F%2Flocalhost%3A44352 &response_type=code &scope=dataEventRecords%20securedFiles%20openid%20profile &nonce=N0.55639781033142241546880026878 &state=15468798618260.8857500931703779 &code_challenge=vBcZBGqBEcQAA3HYf_nSWy6jViRjtGQyiqrrZYUdHHU &code_challenge_method=S256 &ui_locales=de-CH
The STS redirects back to the Angular application with the code and state.
https://localhost:44352/? code=2ee056b556db7dcd5c936686c4b30056e7efd78046eb4e8d4f57c3f6cc638449 &scope=openid%20profile%20dataEventRecords%20securedFiles &state=15468798618260.8857500931703779 &session_state=xQzQduNOGHP7Qh8l5Pjs02piChWuSawPBpDhb2vCmqo.8cccc6873860ec345ea65ead4233c4ee
The client application then requests the tokens using the code:
HTTP POST https://localhost:44318/connect/token BODY grant_type=authorization_code &client_id=angular_code_client &code_verifier=C0.8490756539574429154688002688015468800268800.23727863075955402 &code=2ee056b556db7dcd5c936686c4b30056e7efd78046eb4e8d4f57c3f6cc638449 &redirect_uri=https://localhost:44352
The tokens are then returned and validated. The silent renew works in the same way.
Notes
The Angular application works now using OIDC Code Flow with PKCE to authenticate and authorize, but requires other security protections such as CSP, HSTS XSS protection, and so on. This is a good solution for Angular applications which uses APIs from any domain.
Links
https://tools.ietf.org/html/rfc7636
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
[…] Securing Angular applications using the OpenID Connect Code Flow with PKCE (Damien Bowden) […]
[…] Securing Angular applications using the OpenID Connect Code Flow with PKCE Damien Bowden (@damien_bod) […]
Hi, Can this approach be used against Azure AD (and with implicit flow disabled) please?
Thanks
Jawahar
Hi Jawahar
As far as I know, the answer is no. Azure AD does not support this flow yet.
Greetings Damien
Thanks Damien.
Hi Damien,
It appears that latest Azure AD (i.e. Microsoft Identity platform – https://docs.microsoft.com/en-us/azure/active-directory/develop/about-microsoft-identity-platform) supports “authorization code flow” now. (https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow)
Am I right or missing something here please?
Thanks
Jawahar
Hi Damien,
It appears that latest Azure AD (i.e. Microsoft Identity platform – https://docs.microsoft.com/en-us/azure/active-directory/develop/about-microsoft-identity-platform) supports “authorization code flow” now. (https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow)
Am I right or missing something here please?
Thanks
Jawahar
Hi Damien, Please ignore this comment as I have replied to relevant comment thread.
Thanks
Jawahar
Hi Damien,
If I use single-tenant which is my company and login with my personal account, it keeps bring me to the error page with users not exists in tenant and I can’t logout or pick user the next time I login again. It keeps using the same user identity, am i missing something here?
Hi vouhoa19 this is a Azure AD thing 🙂 you need to set the prompt parameter of the OIDC client to login or select_account, then you can choose. The The reason this happens is because you are logged into a single account.
options.Prompt = “login”; // login, select_acccount
Thank you very much.I have to logout from azure app first before the prompt start working.
Hi Damien, thanks for those examples, they are indeed usefull.
My question is – if we based on this example make PWA like wrap existing Angular SPA in Cordova and build Mobile app, how refresh token would work?
hi Damien,
I’m reading that with the recommended changes from Implicit flow to Auth Code with PKCE it is now possible for SPA’s to use refresh tokens rather than silent renew. From a UX point of view refresh tokens seem to offer a better experience (longer period between needing to sign in again). Do you have a view on this? thanks
Hi Jamie
You should use silent renew and not refresh tokens. This talk is due to the fact that Safari and some time after Chrome will block this, and SPAs will have no other choice but to use refresh tokens… Hopefully by the time this change comes, the refresh token flows will be hardened. So for now use silent renew
Greetings Damien
hi Damien & thanks for your reply, it’s really not clear which way to go from what i read on the web so your opinion is appreciated & I will stick with the silent refresh for now
Thanks for the clear explanation.
There is one thing I would like to know that how can we handle to allow multiple subdomains or domains for the same Net Core Angular application with same Identity Server?
Our current authentication flow is same as your article.
With Regards,
Myo Min Lin
Is it possible to send custom query parameters during code flow ? For example to hint authentication server which idp to use ?
Is it possible to pass custom query parameters in case of code flow during request to authentication server ?