Securing Angular applications using the OpenID Connect Code Flow with PKCE

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

https://www.npmjs.com/package/angular-auth-oidc-client

16 comments

  1. […] Securing Angular applications using the OpenID Connect Code Flow with PKCE (Damien Bowden) […]

  2. […] Securing Angular applications using the OpenID Connect Code Flow with PKCE Damien Bowden (@damien_bod) […]

  3. Jawahar Kaliraj Mariappan · · Reply

    Hi, Can this approach be used against Azure AD (and with implicit flow disabled) please?

    Thanks
    Jawahar

    1. Hi Jawahar

      As far as I know, the answer is no. Azure AD does not support this flow yet.

      Greetings Damien

      1. Jawahar Kaliraj Mariappan · ·

        Thanks Damien.

      2. jawahar75 · ·

        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

  4. jawahar75 · · Reply

    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

    1. jawahar75 · · Reply

      Hi Damien, Please ignore this comment as I have replied to relevant comment thread.

      Thanks
      Jawahar

  5. 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?

    1. 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

  6. Thank you very much.I have to logout from azure app first before the prompt start working.

  7. 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?

  8. Jamie Perks · · Reply

    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

    1. 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

      1. 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

  9. Myo Min Lin · · Reply

    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

Leave a Reply to vanhoa19 Cancel reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: