@hawaii-framework/ngx-oidc-implicit

Open ID Connect - Implicit Flow Service for Angular

Usage no npm install needed!

<script type="module">
  import hawaiiFrameworkNgxOidcImplicit from 'https://cdn.skypack.dev/@hawaii-framework/ngx-oidc-implicit';
</script>

README

OIDC Module

A wrapper for use with Angular on the OIDC Implicit Core package. This package uses the static methods from that library and wraps them with Observables where neccessary.

Features

  • For use with Angular 6 onwards
  • Supports OpenID Connect Implicit Flow
  • Multiple Provider ID's possible in one browser window (scoped tokens)
  • AOT build
  • CSRF Tokens

Installation

npm install @hawaii-framework/oidc-implicit-core @hawaii-framework/ngx-oidc-implicit

Config

Create a constants file (with an Injection Token) within the src dir somewhere with the following code:

import {OidcConfig} from '@hawaii-framework/ngx-oidc-implicit';
import {InjectionToken} from '@angular/core';

export let OIDC_CONFIG_CONSTANTS = new InjectionToken<OidcConfig>('sso-config.constants');

export const OidcConfigDefaults: OidcConfig = {
    response_type: 'id_token token',
    authorisation: `{{ AUTHORISATION URL }}`,
    post_logout_redirect_uri: `{{ POST LOGOUT REDIRECT URL }}`,
    scope: '{{ SCOPES }}',
    token_type: 'Bearer',
    authorize_endpoint: `{{ AUTHORIZE ENDPOINT }}`,
    csrf_token_endpoint: `{{ CSRF ENDPOINT }}`,
    validate_token_endpoint: `{{ TOKEN VALIDATION ENDPOINT }}`,
    is_session_alive_endpoint: `{{ USER SESSION ENDPOINT }}`,
    redirect_uri: `{{ DEFAULT REDIRECT URI }}`,
    login_endpoint: `{{ LOGIN ENDPOINT }}`,
    logout_endpoint: `{{ LOGOUT ENDPOINT }}`,
    restricted_redirect_uris: [`{{ LIST OF COMMA SEPERATE URL PARTS TO RESTRICT AS REDIRECT URL }}`],
    post_logout_provider_ids_to_be_cleaned: [`{{ LIST OF COMMA SEPERATE CLIENT ID'S IN USE WITH THIS SSO SERVER }}`],
    silent_refresh_uri: `{{ SILENT REFRESH LOCATION, THIS IS USUALLY JUST AN EMTPY HTML FILE LCOATED SOMEWHERE }}`,
    silent_logout_uri: `{{ YOUR FRONTEND LOGOUT URL, TO BE USED IN THE SILENT LOGOUT IFRAME }}`,
    provider_id: '{{ PROVIDER ID }}',
    client_id: '{{ CLIENT ID }}',
};

Implementation

app.module.ts

Add to your App Module as forRoot for a single instance across the project.

@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [
        BrowserModule,
        HttpClientModule,
        OidcModule.forRoot(),
    ],
    providers: [
        {provide: SSO_CONFIG_CONSTANTS, useValue: SsoConfigConstants},
    ],
    bootstrap: [AppComponent]
})
export class AppModule {
}

auth.guard.ts

In your scaffolded setup, add a Guard. If you're using multiple lazy loaded modules, make sure you add the guard to your Shared Module

@Injectable()
export class AuthGuard implements CanActivate {

    constructor(private _oidcService: OidcService,
                @Inject(SSO_CONFIG_CONSTANTS) private _ssoConfigConstants: OidcConfig,
                @Inject(APP_CONSTANTS) private _appConstants: AppConstantsModel,
                private _pls: PathLocationStrategy,
                private _router: Router) {
        this._oidcService.config = this._ssoConfigConstants;
    }

    canActivate(next: ActivatedRouteSnapshot,
                state: RouterStateSnapshot): Observable<boolean> {

        return new Observable(observer => {
            const port: string = window.location.port,
                protocol: string = window.location.protocol,
                hostname: string = window.location.hostname,
                baseRedirectUri = `${protocol}//${hostname}${port ? `:${port}` : ''}`,
                localToken = this._oidcService.getStoredToken();

            // Set the current URL as redirect
            let redirectURI = `${baseRedirectUri}${this._pls.getBaseHref()}${state.url}`;

            // Check if we can redirect to this uri, if not, go to default
            this._oidcService.config.restricted_redirect_uris.forEach(restrictedUriPart => {
                if (state.url.indexOf(restrictedUriPart) !== -1) {
                    redirectURI = `${baseRedirectUri}`;
                }
            });

            // Set the redirect uri in this instance
            this._oidcService.config.redirect_uri = redirectURI;

            // Do the session check
            this._oidcService.checkSession()
                .pipe(
                    first()
                )
                .subscribe((authenticated: boolean) => {
                        // Check if the token expires in the next (x) seconds,
                        // if so, set trigger a silent refresh of the Access Token in the OIDC Service.
                        if (localToken && localToken.expires - Math.round(new Date().getTime() / 1000.0) < 3000) {
                            this._oidcService.silentRefreshAccessToken().subscribe();
                        }

                        // Set the next value to authenticated value
                        // Complete the observer
                        observer.next(authenticated);
                        observer.complete();
                    },
                    () => {
                        // Do something with the error, because most likely your OIDC provider is down.
                    }
                );
        });
    }
}

someModule-routing.modules.ts

Use the guard on routes:

const routes: Routes = [
    {
        path: '',
        component: SomeComponent,
        canActivate: [AuthGuard],
    },
];

adding the bearer token to rest-calls

Example of adding Bearer header to rest calls. I use a service wrapper for this:

@Injectable()
export class RestService {

    private _headers = new HttpHeaders();

    constructor(private _http: HttpClient,
                @Inject(SSO_CONFIG_CONSTANTS) private _ssoConfigConstants: OidcConfig,
                private _oidcService: OidcService){

        // Set the config according to globals set for this app
        this._oidcService.config = this._ssoConfigConstants;


        // Append the JSON content type header
        this._headers = this._headers.set('Content-Type', 'application/json');
    }

    public get(url: string, requiresAuthHeaders: boolean, queryParams?: object | undefined): Observable<any> {
        const options: any = {};

        if (requiresAuthHeaders) {
            this._setAuthHeader();
        }

        let params = new HttpParams();
        if (queryParams) {

            Object.keys(queryParams)
                .map(key => {
                    params = params.set(key, queryParams[key]);
                });

            options.params = params;
        }


        options.headers = this._headers;

        return this._http
            .get(url, options)
            .pipe(catchError((err: HttpErrorResponse) => {
                return observableThrowError(err.error);
            }));
    }

    public post(url: string, data: any, requiresAuthHeaders: boolean): Observable<any> {

        if (requiresAuthHeaders) {
            this._setAuthHeader();
        }

        return this._http
            .post(url, data, {
                headers: this._headers
            })
            .pipe(catchError((err: HttpErrorResponse) => {
                return observableThrowError(err.error);
            }));
    }

    public put(url: string, data: any, requiresAuthHeaders: boolean): Observable<any> {

        if (requiresAuthHeaders) {
            this._setAuthHeader();
        }

        return this._http
            .put(url, data, {
                headers: this._headers
            })
            .pipe(catchError((err: HttpErrorResponse) => {
                return observableThrowError(err.error);
            }));
    }

    public patch(url: string, data: any, requiresAuthHeaders: boolean): Observable<any> {

        if (requiresAuthHeaders) {
            this._setAuthHeader();
        }

        return this._http
            .patch(url, data, {
                headers: this._headers
            })
            .pipe(catchError((err: HttpErrorResponse) => {
                return observableThrowError(err.error);
            }));
    }

    public delete(url: string): Observable<any> {
        this._setAuthHeader();
        return this._http.delete(url, {
                headers: this._headers
            })
            .pipe(catchError((err: HttpErrorResponse) => {
                return observableThrowError(err.error);
            }));
    }

    private _setAuthHeader() {
        // Get local token from the OIDC Service
        const localToken = this._oidcService.getStoredToken();

        // Check if local token is there
        if (localToken) {

            // Set the header
            this._headers = this._headers.set('Authorization', this._oidcService.getAuthHeader());

            // Check if the token expires in the next (x) seconds,
            // if so, set trigger a silent refresh of the Access Token in the OIDC Servic
            if (localToken.expires - Math.round(new Date().getTime() / 1000.0) < 3000) {
                this._oidcService.silentRefreshAccessToken().subscribe();
            }
        }

        // There's no local token present, so do a checkSession to get a new one
        else {
            this._oidcService.checkSession()
                .subscribe((loggedIn: boolean) => {
                    if (loggedIn) {
                        this._setAuthHeader();
                    }
                });
        }
    }
}

custom login page

You can configure a custom login page, that's part of the angular stack, therefore there is a login endpoint in the config. Make sure you point the OIDC config to the proper URL within the angular stack. After that a login page is pretty straight forward. The form should (for security purposes) be a classic form HTTP POST.

Here is the bare basics:

login.component.html

<form ngNoForm
      action="{{ oidcService.config.login_endpoint }}"
      method="post">
    <fieldset>
        <legend>Log In</legend>

        <!-- CSRF Token -->
        <input type="hidden" name="_csrf" [formControl]="_csrf">

        <!-- Email or username -->
        <input type="email"
               id="j_username"
               [formControl]="j_username"
               name="j_username">

        <!-- Password-->
        <input type="password"
               id="j_password"
               [formControl]="j_password"
               name="j_password"
               autocomplete="off">

        <!-- Submit -->
        <button>Log In</button>
    </fieldset>
</form>

login.component.ts

@Component({
    selector: 'app-login',
    templateUrl: './login.component.html',
})
export class LoginComponent implements OnInit, OnDestroy {

    /**
     * CSRF token
     * @type {FormControl}
     * @private
     */
    public _csrf: FormControl = new FormControl('', Validators.required);
    /**
     * Username or E-mail address
     * @type {FormControl}
     */
    public j_username: FormControl = new FormControl('', Validators.required);

    /**
     * Password form
     * @type {FormControl}
     */
    public j_password: FormControl = new FormControl('', Validators.required);

    constructor(public oidcService: OidcService,
                @Inject(SSO_CONFIG_CONSTANTS) private _ssoConfigConstants: OidcConfig,
              ) {

        this.oidcService.config = this._ssoConfigConstants;
    }

    ngOnInit() {
        this.oidcService.getCsrfToken()
            .subscribe(
                (token: CsrfToken) => this._csrf.setValue(token.csrf_token));
    }
}

Publishing

npm publish dist