Skip to content

Commit

Permalink
feat(oidc): adding native OIDC module (WIP)
Browse files Browse the repository at this point in the history
this module will have no 3rd party dependencies and leverage NGXS
  • Loading branch information
xmlking committed Feb 18, 2019
1 parent 10e5f95 commit cb4b044
Show file tree
Hide file tree
Showing 29 changed files with 1,052 additions and 0 deletions.
22 changes: 22 additions & 0 deletions libs/oidc/README.md
@@ -0,0 +1,22 @@
# OIDC


> Redirect behaviour after `login()` and `logout()`
1. if `postLoginRedirectUri` is provided then `login()` call without `args`, will redirect to this URL after successful login.
2. if `postLogoutRedirectUri` is provided then `logout()` call without `args`, will redirect to this URL after successful logout.
3. if both of the above urls are configured, but if `redirectUri` is provided, will redirect to this URL after successful login and logout.
4. if non of the above urls are configured, will redirect to `currentUrl` after successful login and logout.
5. always `args` in `login()` and `logout()` take priority.

> if the above URLs are relative URLs, then, the `OIDC Provider` may prepend `baseUrl` configured in `OIDC Provider`.

### Flows
1. [Silent Refresh](https://www.scottbrady91.com/OpenID-Connect/Silent-Refresh-Refreshing-Access-Tokens-when-using-the-Implicit-Flow
) for Implicit Flow
2. Auto Refresh for Code Flow


TODO
https://github.com/damienbod/angular-auth-oidc-client
5 changes: 5 additions & 0 deletions libs/oidc/jest.config.js
@@ -0,0 +1,5 @@
module.exports = {
name: 'oidc',
preset: '../../jest.config.js',
coverageDirectory: '../../coverage/libs/oidc',
};
11 changes: 11 additions & 0 deletions libs/oidc/ng-package.json
@@ -0,0 +1,11 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/libs/oidc",
"whitelistedNonPeerDependencies": ["keycloak-js"],
"lib": {
"entryFile": "src/index.ts",
"umdModuleIds": {
"keycloak-js": "keycloak-js"
}
}
}
15 changes: 15 additions & 0 deletions libs/oidc/package.json
@@ -0,0 +1,15 @@
{
"name": "@ngx-starter-kit/oidc",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": ">=6.0.0 <8.0.0",
"@angular/core": ">=6.0.0 <8.0.0",
"@ngxs/store": ">=3.3.0",
"@ngxs/router-plugin": ">=3.3.0",
"@ngxs/storage-plugin": ">=3.3.0",
"@ngxs-labs/immer-adapter": ">=1.1.0"
},
"dependencies": {
"keycloak-js": ">=4.8.3 <5.0.0"
}
}
7 changes: 7 additions & 0 deletions libs/oidc/src/index.ts
@@ -0,0 +1,7 @@
export * from './lib/types';
export * from './lib/oidc.module';
export * from './lib/services/auth.service';
export * from './lib/state/auth.state';
export * from './lib/state/auth.actions';
export * from './lib/guards/base-auth.guard';
export * from './lib/guards/auth.guard';
40 changes: 40 additions & 0 deletions libs/oidc/src/lib/guards/auth.guard.ts
@@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from '@angular/router';
import { MatSnackBar } from '@angular/material';
import { environment } from '@env/environment';
import { BaseAuthGuard } from './base-auth.guard';
import { AuthService } from '../services/auth.service';

@Injectable()
export class AuthGuard extends BaseAuthGuard {
constructor(private snack: MatSnackBar, protected router: Router, protected authService: AuthService) {
super(router, authService);
}

async isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean | UrlTree> {
if (!this.authenticated) {
await this.authService.login({ redirectUri: environment.baseUrl.slice(0, -1) + state.url }); // Let Provider add baseUrl?
return;
}

const requiredRoles = route.data.roles;
if (!requiredRoles || requiredRoles.length === 0) {
return true;
} else {
if (!this.roles || this.roles.length === 0) {
return false;
}
let granted = false;
for (const requiredRole of requiredRoles) {
if (this.roles.indexOf(requiredRole) > -1) {
granted = true;
break;
}
}
if (!granted) {
this.snack.open('You are not Admin. Please login with ngxadmin : ngxadmin', 'OK', { duration: 5000 });
}
return granted;
}
}
}
22 changes: 22 additions & 0 deletions libs/oidc/src/lib/guards/base-auth.guard.ts
@@ -0,0 +1,22 @@
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { AuthService } from '../services/auth.service';

export abstract class BaseAuthGuard implements CanActivate {
protected authenticated: boolean;
protected roles: string[];

protected constructor(protected router: Router, protected authService: AuthService) {}

async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean | UrlTree> {
try {
this.authenticated = await this.authService.isLoggedIn();
this.roles = await this.authService.getUserRoles(true);

return await this.isAccessAllowed(route, state);
} catch (error) {
throw new Error('An error happened during access validation. Details:' + error);
}
}

abstract isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean | UrlTree>;
}
50 changes: 50 additions & 0 deletions libs/oidc/src/lib/interceptors/default-resource.interceptor.ts
@@ -0,0 +1,50 @@
import { Inject, Injectable, Optional } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { from, Observable } from 'rxjs';
import { catchError, mergeMap } from 'rxjs/operators';
import { ResourceErrorHandler } from './resource-error-handler';

import { AuthService } from '../services/auth.service';
import { OidcResourceInterceptorConfig } from '../types';

@Injectable()
export class DefaultResourceInterceptor implements HttpInterceptor {
bypass: boolean;
bearerPrefix: string;
authorizationHeaderName: string;
constructor(
private authService: AuthService,
private errorHandler: ResourceErrorHandler,
@Optional() private resourceInterceptorConfig: OidcResourceInterceptorConfig,
) {
this.bypass = !this.authService || !this.resourceInterceptorConfig || !this.resourceInterceptorConfig.allowedUrls;
this.bearerPrefix = resourceInterceptorConfig.bearerPrefix.trim().concat(' ');
this.authorizationHeaderName = resourceInterceptorConfig.authorizationHeaderName;
}

private checkUrl(url: string): boolean {
const found = this.resourceInterceptorConfig.allowedUrls.find(u => url.startsWith(u));
return !!found;
}

public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (this.bypass) {
// TODO: do we need this check?
return next.handle(req);
}

if (!this.checkUrl(req.url.toLowerCase())) {
return next.handle(req);
}

return from(this.authService.getToken()).pipe(
mergeMap(accessToken => {
if (accessToken) {
const headers = req.headers.set(this.authorizationHeaderName, this.bearerPrefix + accessToken);
req = req.clone({ headers });
}
return next.handle(req).pipe(catchError(err => this.errorHandler.handleError(err)));
}),
);
}
}
12 changes: 12 additions & 0 deletions libs/oidc/src/lib/interceptors/resource-error-handler.ts
@@ -0,0 +1,12 @@
import { HttpResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';

export abstract class ResourceErrorHandler {
abstract handleError(err: HttpResponse<any>): Observable<any>;
}

export class NoopResourceErrorHandler implements ResourceErrorHandler {
handleError(err: HttpResponse<any>): Observable<any> {
return throwError(err);
}
}
23 changes: 23 additions & 0 deletions libs/oidc/src/lib/oidc.init.ts
@@ -0,0 +1,23 @@
import { Store } from '@ngxs/store';
import { LoginSuccess } from './state/auth.actions';
import { AuthorizationErrorResponse } from './types';
import { AuthService } from '@ngx-starter-kit/oidc';

export function initializeAuth(authService: AuthService, store: Store) {
// TODO only check if not logged in. use Store and localStorage?
// authService.setStorage(sessionStorage);
return async () => {
let authenticated = false;
try {
authenticated = await authService.init();
} catch (err /*: AuthorizationErrorResponse */) {
console.log(`Error Code: ${err.error}, Error Description: ${err.error_description}`);
}

if (authenticated) {
const profile = await authService.loadUserProfile();
store.dispatch(new LoginSuccess(profile));
}
return authenticated;
};
}
72 changes: 72 additions & 0 deletions libs/oidc/src/lib/oidc.module.ts
@@ -0,0 +1,72 @@
import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgxsModule, Store } from '@ngxs/store';

import { initializeAuth } from './oidc.init';
import { AuthGuard } from './guards/auth.guard';
import { AuthState } from './state/auth.state';
import { AuthHandler } from './state/auth.handler';
import { AuthService } from './services/auth.service';
import { PingService } from './services/ping.service';
import { KeycloakService } from './services/keycloak.service';
import { GenericService } from './services/generic.service';
import { DefaultResourceInterceptor } from './interceptors/default-resource.interceptor';
import { NoopResourceErrorHandler, ResourceErrorHandler } from './interceptors/resource-error-handler';
import {
OidcInitConfig,
OidcModuleConfig,
OidcProvider,
OidcProviderConfig,
OidcResourceInterceptorConfig,
} from './types';

function getProvider(providerConfig) {
if (providerConfig) {
switch (providerConfig.provider) {
case OidcProvider.Ping:
return PingService;
case OidcProvider.Keycloak:
return KeycloakService;
default:
return GenericService;
}
} else {
return GenericService;
}
}
@NgModule({
imports: [CommonModule, NgxsModule.forFeature([AuthState])],
})
export class OidcModule {
static forRoot(config: OidcModuleConfig): ModuleWithProviders {
return {
ngModule: OidcModule,
providers: [
{ provide: OidcModuleConfig, useValue: Object.assign(new OidcModuleConfig(), config) },
{ provide: OidcInitConfig, useValue: Object.assign(new OidcInitConfig(), config.initConfig) },
{ provide: OidcProviderConfig, useValue: Object.assign(new OidcProviderConfig(), config.providerConfig) },
{
provide: OidcResourceInterceptorConfig,
useValue: Object.assign(new OidcResourceInterceptorConfig(), config.resourceInterceptorConfig),
},
{
provide: AuthService,
useClass: getProvider(config.providerConfig),
},
{ provide: ResourceErrorHandler, useClass: NoopResourceErrorHandler },
config.resourceInterceptorConfig && config.resourceInterceptorConfig.allowedUrls
? {
provide: HTTP_INTERCEPTORS,
useClass: DefaultResourceInterceptor,
multi: true,
}
: [],
AuthGuard,
{ provide: APP_INITIALIZER, useFactory: initializeAuth, deps: [AuthService, Store], multi: true },
],
};
}
// HINT: AuthHandler is injected here to initialize it as Module Run Block
constructor(authHandler: AuthHandler) {}
}
12 changes: 12 additions & 0 deletions libs/oidc/src/lib/services/auth.service.spec.ts
@@ -0,0 +1,12 @@
import { TestBed } from '@angular/core/testing';

import { AuthService } from './auth.service';

describe('AuthService', () => {
beforeEach(() => TestBed.configureTestingModule({}));

it('should be created', () => {
const service: AuthService = TestBed.get(AuthService);
expect(service).toBeTruthy();
});
});
34 changes: 34 additions & 0 deletions libs/oidc/src/lib/services/auth.service.ts
@@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { OidcModuleConfig, OidcInitConfig, OidcLoginOptions, OidcUserProfile } from '../types';

@Injectable()
export abstract class AuthService {
protected userProfile: OidcUserProfile;
protected readonly silentRefresh: boolean;
protected readonly loadUserProfileAtStartUp: boolean;
protected readonly postLoginRedirectUri: string;
protected readonly postLogoutRedirectUri: string;

protected constructor(protected moduleConfig: OidcModuleConfig) {
const { loadUserProfileAtStartUp = true, postLoginRedirectUri, postLogoutRedirectUri, initConfig } = moduleConfig;
this.loadUserProfileAtStartUp = loadUserProfileAtStartUp;
this.postLoginRedirectUri = postLoginRedirectUri;
this.postLogoutRedirectUri = postLogoutRedirectUri;
this.silentRefresh = initConfig ? initConfig.flow === 'implicit' : false;
}

abstract init(options?: OidcInitConfig): Promise<boolean>;
abstract login(options?: OidcLoginOptions): Promise<void>;
abstract logout(redirectUri?: string): Promise<void>;
abstract register(options?: OidcLoginOptions);
abstract getAccountManagementUrl();
abstract isUserInRole(role: string, resource?: string): boolean;
abstract getUserRoles(allRoles?: boolean): string[];
abstract isLoggedIn(): Promise<boolean>;
abstract isTokenExpired(minValidity?: number): boolean;
abstract updateToken(minValidity?: number): Promise<boolean>;
abstract loadUserProfile(forceReload?: boolean): Promise<OidcUserProfile>;
abstract getToken(): Promise<string>;
abstract getUsername(): string;
abstract clearToken();
}
12 changes: 12 additions & 0 deletions libs/oidc/src/lib/services/generic.service.spec.ts
@@ -0,0 +1,12 @@
import { TestBed } from '@angular/core/testing';

import { GenericService } from './generic.service';

describe('GenericService', () => {
beforeEach(() => TestBed.configureTestingModule({}));

it('should be created', () => {
const service: GenericService = TestBed.get(GenericService);
expect(service).toBeTruthy();
});
});

0 comments on commit cb4b044

Please sign in to comment.