Skip to content

Instantly share code, notes, and snippets.

@hidegh
Created October 25, 2021 12:48
Show Gist options
  • Save hidegh/8dcac31ce0825248030bea1611a1abc6 to your computer and use it in GitHub Desktop.
Save hidegh/8dcac31ce0825248030bea1611a1abc6 to your computer and use it in GitHub Desktop.
authService - WorkFlowWise (wfw-ngx-adal - with acquireToken patch) and custom JWT.ts
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { JwtHelperService } from '@auth0/angular-jwt';
import { BehaviorSubject, from, Observable, of } from "rxjs";
import { distinctUntilChanged, take, tap } from "rxjs/operators";
import { RoleConsts } from "src/app/_core/security/roles-consts";
import { CurrentUserService } from "src/app/_services/current-user/current-user.service";
import { IUserProfile } from "src/app/_services/current-user/i.user.profile";
import { environment } from 'src/environments/environment';
import { AdalService } from "wfw-ngx-adal/dist/core";
export enum AuthTypeEnum {
NoAuth = 0,
Regular = 1,
Jwt = 2
}
export interface IAuthService {
login(redirectPath?: string): Promise<void>;
logout(redirectPath?: string): Promise<void>;
handleCallback(redirectPath?: string): Promise<void>;
loggedIn: boolean;
/** Tries to log in silently with existing (cached) credentials (both ADAL and JWT login supported) */
tryLoginSilently(redirectPath?: string): Promise<boolean>;
/** Tries to log in with JWT only, bypasssing the "standard" adal - allowing access for unregistered employees */
loginWithToken(token: string, redirectPath?: string): Promise<void>
}
export interface ISecurityService {
hasClaim(type: any, value: any): boolean;
isInRole(roleName: string): boolean;
}
/**
* Originally updated to: import { AdalService } from "wfw-ngx-adal/dist/core";
*
* Consider official Microsoft library:
* https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-angular
* - https://youtu.be/TkCKqeYjpv0?t=1002
* - https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/samples/msal-angular-v2-samples/angular12-sample-app
*
* Docs:
* - https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-js-sso
*/
@Injectable({
providedIn: 'root'
})
export class AuthService implements IAuthService, ISecurityService {
public static readonly accessTokenKey = "access_token";
public static readonly accessTypeKey = "access_type";
public static minimumRequiredTokenLifetimeSeconds = 15 /* mins */ * 60;
public get authType(): AuthTypeEnum { return (<any>AuthTypeEnum)[localStorage.getItem(AuthService.accessTypeKey) || ""] || AuthTypeEnum.NoAuth; }
public get token(): string { return localStorage.getItem(AuthService.accessTokenKey) || ""; }
protected _loggedIn: boolean = false;
protected _userProfile: IUserProfile = {} as IUserProfile;
public get loggedIn(): boolean { return this._loggedIn; }
public get userProfile(): IUserProfile { return this._userProfile; }
/**
* @description
* Helper Subject (observable) for the loggedInStatusChanged$
* Aft. change to BehaviorSubject: also a way to access logged-in status!
*/
public loggedIn$ = new BehaviorSubject<boolean>(false);
/**
* @description
* This observable will emit when loggedIn status changes.
* Initially won't emit anything, until a check via login/ssoLogin/logout/isAuthenticated is done (which initiates this trigger).
*/
public loggedInStatusChanged$: Observable<boolean> = this.loggedIn$.pipe(distinctUntilChanged());
/**
* @description
* Helper method to emit new values into the protected loggedInStatusChangedSubject$.
* If the loggedInStatusChangedSubject$'s value changes, then the public loggedInStatusChanged$ will emit as well.
* @param loggedIn authentication result
*/
protected emitLoggedInStatus(loggedIn: boolean) {
// NOTE: must set value here, as there's no guarantee that the loggedInStatusChanged$ will be subscribed!
this._loggedIn = loggedIn;
this.loggedIn$.next(loggedIn);
}
protected jwtHelperService = new JwtHelperService({
tokenGetter: (request: any) => localStorage.getItem(AuthService.accessTokenKey)
});
constructor(
protected router: Router,
protected adal: AdalService,
protected currentUserService: CurrentUserService,
) {
const secret: adal.Config = {
tenant: environment.adal!.tenant!,
clientId: environment.adal!.clientId!,
redirectUri: window.location.origin + environment.adal!.redirectUri!,
navigateToLoginRequestUrl: environment.adal!.navigateToLoginRequestUrl!,
cacheLocation: environment.adal!.cacheLocation!
};
// NOTE: it seems that the post-logout-redirect-uri works on prod, but not on the localhost!
if (environment.adal!.postLogoutRedirectUri!)
secret.postLogoutRedirectUri = window.location.origin + environment.adal!.postLogoutRedirectUri!
this.adal.init(secret);
this.adal.handleWindowCallback();
}
protected isAuthenticated(): boolean {
if (this.authType == AuthTypeEnum.Regular) return !!this.adal.userInfo?.isAuthenticated;
else if (this.authType == AuthTypeEnum.Jwt) return this.jwtHelperService.isTokenExpired(/* undefined, AuthService.minimumRequiredTokenLifetimeSeconds */) === false;
return false;
}
public async login(redirectPath?: string): Promise<void> {
const silentLoginResult = await this.tryLoginSilently(redirectPath);
if (!silentLoginResult) {
// do regular login (will do redirect to callback)
console.log('auth.login');
this.adal.login();
}
return Promise.resolve(undefined);
}
public async tryLoginSilently(redirectPath?: string): Promise<boolean> {
if (this.authType == AuthTypeEnum.Regular) {
// ADAL
if (this.isAuthenticated()) {
console.log('auth.login (silent)');
// NOTE: validity is checked by the adal.acquireToken call itself!
const token = await this.acquireToken();
// set up user session
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Regular, redirectPath);
// success (logged in silently via persisted token data)
return of(true).toPromise();
}
} else if (this.authType == AuthTypeEnum.Jwt) {
// JWT
const token = this.token;
const hasValidToken = token && !this.jwtHelperService.isTokenExpired(token/*, AuthService.minimumRequiredTokenLifetimeSeconds*/);
if (hasValidToken) {
console.log('auth.login (silent)');
// set up user session
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Jwt, redirectPath);
// success (logged in silently via persisted token data)
return of(true).toPromise();
}
}
// no token (or token expired), need to do login
return of(false).toPromise();
}
public async loginWithToken(token: string, redirectPath?: string): Promise<void> {
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Jwt, redirectPath);
return Promise.resolve(undefined);
}
public async logout(redirectPath?: string): Promise<void> {
console.log('auth.logout');
// store for later usage as the teardown clears this value
const loggedInAuthType = this.authType;
// clear all local...
await this.tearDownUserSessionAndEmitFalse(redirectPath);
// logout (cleanup) via adal
if (loggedInAuthType == AuthTypeEnum.Regular)
this.adal.logOut();
return Promise.resolve(undefined);
}
public async handleCallback(redirectPath?: string): Promise<void> {
console.log('auth.handleCallback');
// execute any callback related code...
// ...
// NOTE: for the this.isAuthenticated() to work, we must signalize that we're doing the regular auth.
localStorage.setItem(AuthService.accessTypeKey, AuthTypeEnum[AuthTypeEnum.Regular]);
if (this.isAuthenticated()) {
const token = await this.acquireToken();
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Regular, redirectPath);
} else {
await this.tearDownUserSessionAndEmitFalse(redirectPath);
}
return Promise.resolve(undefined);
}
protected async acquireToken(): Promise<string> {
let token = "";
try {
return from(
this.adal
.acquireToken(this.adal.config.clientId))
.pipe(
take(1),
tap((result: any) => { token = result.toString(); })
)
.toPromise();
}
catch (e) {
// Ignore the one teardown error that is cause the adal.acquireToken uses an extra return s statement...
// ...usually the token is already fetched, just some promise/async finish up code results in the error!
if (e.toString().startsWith('Error: unrecognized teardown'))
return of(token).toPromise();
// ...otherwise re-throw
throw e;
}
}
protected setupUserSessionAndEmitTrue(token: string, authType: AuthTypeEnum = AuthTypeEnum.Regular, redirectPath?: string): Promise<void> {
const fn = async () => {
// setup user session & emit logged in status TRUE
console.info('auth.setupUserSessionAndEmitTrue');
// set cache
localStorage.setItem(AuthService.accessTokenKey, token);
localStorage.setItem(AuthService.accessTypeKey, AuthTypeEnum[authType]);
// get user data (the API itself will decide if a new "login audit event" should be created)
const userData = await this.currentUserService.getCurrentUserProfile().toPromise();
this._userProfile = userData;
// emit
this.emitLoggedInStatus(true);
// redirect
if (redirectPath) this.router.navigateByUrl(redirectPath);
};
return fn();
}
protected tearDownUserSessionAndEmitFalse(redirectPath?: string): Promise<void> {
// setup user session & emit logged in status FALSE
console.info('auth.tearDownUserSessionAndEmitFalse');
// clear cache
delete localStorage[AuthService.accessTokenKey];
delete localStorage[AuthService.accessTypeKey];
this._userProfile = {} as IUserProfile;
// emit
this.emitLoggedInStatus(false);
// redirect
if (redirectPath) this.router.navigateByUrl(redirectPath);
return Promise.resolve(undefined);
}
//
// Security support from here...
//
public hasClaim(type: any, value: any): boolean {
// NOTE: case sensitive!
return !!this._userProfile?.claims?.some(claim => claim.type == type && claim.value == value);
}
public getClaims(type: any): any[] {
return this._userProfile?.claims?.filter(claim => claim.type == type);
}
public isInRole(roleName: string): boolean {
return this.hasClaim(RoleConsts.$RoleClaimType, roleName);
}
//
// Logging
//
public getLogDetails(): any {
return {
dotNetUserId: this._userProfile?.dotNetUserId,
email: this._userProfile?.email,
phone: this._userProfile?.phone,
claims: this._userProfile?.claims?.filter(claim => claim.type == RoleConsts.$RoleClaimType),
};
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment