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
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", "log_console_warning_active": true, "log_console_debug_active": true, "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 { HttpClientModule } from '@angular/common/http'; import { AuthModule } from './auth/modules/auth.module'; import { OidcSecurityService } from './auth/services/oidc.security.service'; import { OpenIDImplicitFlowConfiguration } from './auth/modules/auth.configuration'; import { OidcConfigService } from './auth/services/oidc.security.config.service'; import { AuthWellKnownEndpoints } from './auth/models/auth.well-known-endpoints'; // Add then other imports, config you need export function loadConfig(oidcConfigService: OidcConfigService) { console.log('APP_INITIALIZER STARTING'); return () => oidcConfigService.load(`${window.location.origin}/api/ClientAppSettings`); } @NgModule({ imports: [ BrowserModule, FormsModule, routing, HttpClientModule, AuthModule.forRoot(), ], declarations: [ AppComponent, ], providers: [ OidcConfigService, OidcSecurityService, { provide: APP_INITIALIZER, useFactory: loadConfig, deps: [OidcConfigService], multi: true }, Configuration ], bootstrap: [AppComponent], }) export class AppModule { constructor( private oidcSecurityService: OidcSecurityService, private oidcConfigService: OidcConfigService, configuration: Configuration ) { this.oidcConfigService.onConfigurationLoaded.subscribe(() => { const openIDImplicitFlowConfiguration = new OpenIDImplicitFlowConfiguration(); openIDImplicitFlowConfiguration.stsServer = this.oidcConfigService.clientConfiguration.stsServer; openIDImplicitFlowConfiguration.redirect_url = this.oidcConfigService.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.oidcConfigService.clientConfiguration.client_id; openIDImplicitFlowConfiguration.response_type = this.oidcConfigService.clientConfiguration.response_type; openIDImplicitFlowConfiguration.scope = this.oidcConfigService.clientConfiguration.scope; openIDImplicitFlowConfiguration.post_logout_redirect_uri = this.oidcConfigService.clientConfiguration.post_logout_redirect_uri; openIDImplicitFlowConfiguration.start_checksession = this.oidcConfigService.clientConfiguration.start_checksession; openIDImplicitFlowConfiguration.silent_renew = this.oidcConfigService.clientConfiguration.silent_renew; openIDImplicitFlowConfiguration.silent_renew_url = this.oidcConfigService.clientConfiguration.redirect_url + '/silent-renew.html'; openIDImplicitFlowConfiguration.post_login_route = this.oidcConfigService.clientConfiguration.startup_route; // HTTP 403 openIDImplicitFlowConfiguration.forbidden_route = this.oidcConfigService.clientConfiguration.forbidden_route; // HTTP 401 openIDImplicitFlowConfiguration.unauthorized_route = this.oidcConfigService.clientConfiguration.unauthorized_route; openIDImplicitFlowConfiguration.log_console_warning_active = this.oidcConfigService.clientConfiguration.log_console_warning_active; openIDImplicitFlowConfiguration.log_console_debug_active = this.oidcConfigService.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.oidcConfigService.clientConfiguration.max_id_token_iat_offset_allowed_in_seconds; // openIDImplicitFlowConfiguration.iss_validation_off = false; configuration.FileServer = this.oidcConfigService.clientConfiguration.apiFileServer; configuration.Server = this.oidcConfigService.clientConfiguration.apiServer; const authWellKnownEndpoints = new AuthWellKnownEndpoints(); authWellKnownEndpoints.setWellKnownEndpoints(this.oidcConfigService.wellKnownEndpoints); this.oidcSecurityService.setupModule(openIDImplicitFlowConfiguration, authWellKnownEndpoints); }); 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.
private doCallbackLogicIfRequired() { console.log(window.location); // Will do a callback, if the url has a code and state parameter. this.oidcSecurityService.authorizedCallbackWithCode(window.location.toString()); }
or if you want more control, or have specific logic:
private doCallbackLogicIfRequired() { console.log(window.location); const urlParts = window.location.toString().split('?'); const params = new HttpParams({ fromString: urlParts[1] }); const code = params.get('code'); const state = params.get('state'); const session_state = params.get('session_state'); if (code && state && session_state) { this.oidcSecurityService.requestTokensWithCode(code, state, session_state); } }
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