diff --git a/projects/angular-jwt/src/lib/jwt.interceptor.ts b/projects/angular-jwt/src/lib/jwt.interceptor.ts index 11f3fbfd..a6f8dbb0 100644 --- a/projects/angular-jwt/src/lib/jwt.interceptor.ts +++ b/projects/angular-jwt/src/lib/jwt.interceptor.ts @@ -9,9 +9,15 @@ import { DOCUMENT } from '@angular/common'; import { JwtHelperService } from './jwthelper.service'; import { JWT_OPTIONS } from './jwtoptions.token'; -import { mergeMap } from 'rxjs/operators'; -import { from, Observable } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { defer, from, Observable, of } from 'rxjs'; +const fromPromiseOrValue = (input: T | Promise) => { + if (input instanceof Promise) { + return defer(() => input); + } + return of(input); +}; @Injectable() export class JwtInterceptor implements HttpInterceptor { tokenGetter: ( @@ -103,25 +109,32 @@ export class JwtInterceptor implements HttpInterceptor { next: HttpHandler ) { const authScheme = this.jwtHelper.getAuthScheme(this.authScheme, request); - let tokenIsExpired = false; if (!token && this.throwNoTokenError) { throw new Error('Could not get token from tokenGetter function.'); } + let tokenIsExpired = of(false); + if (this.skipWhenExpired) { - tokenIsExpired = token ? this.jwtHelper.isTokenExpired(token) : true; + tokenIsExpired = token ? fromPromiseOrValue(this.jwtHelper.isTokenExpired(token)) : of(true); } - if (token && tokenIsExpired && this.skipWhenExpired) { - request = request.clone(); - } else if (token) { - request = request.clone({ - setHeaders: { - [this.headerName]: `${authScheme}${token}`, - }, - }); + if (token) { + return tokenIsExpired.pipe( + map((isExpired) => + isExpired && this.skipWhenExpired + ? request.clone() + : request.clone({ + setHeaders: { + [this.headerName]: `${authScheme}${token}`, + }, + }) + ), + mergeMap((innerRequest) => next.handle(innerRequest)) + ); } + return next.handle(request); } @@ -134,14 +147,10 @@ export class JwtInterceptor implements HttpInterceptor { } const token = this.tokenGetter(request); - if (token instanceof Promise) { - return from(token).pipe( - mergeMap((asyncToken: string | null) => { - return this.handleInterception(asyncToken, request, next); - }) - ); - } else { - return this.handleInterception(token, request, next); - } + return fromPromiseOrValue(token).pipe( + mergeMap((asyncToken: string | null) => { + return this.handleInterception(asyncToken, request, next); + }) + ); } } diff --git a/projects/angular-jwt/src/lib/jwthelper.service.spec.ts b/projects/angular-jwt/src/lib/jwthelper.service.spec.ts new file mode 100644 index 00000000..24d9a5a3 --- /dev/null +++ b/projects/angular-jwt/src/lib/jwthelper.service.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, +} from '@angular/common/http/testing'; +import { JwtModule, JwtHelperService } from 'angular-jwt'; + +describe('Example HttpService: with simple based tokken getter', () => { + let service: JwtHelperService; + const tokenGetter = jasmine.createSpy('tokenGetter'); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + JwtModule.forRoot({ + config: { + tokenGetter: tokenGetter, + allowedDomains: ['example-1.com', 'example-2.com', 'example-3.com'], + }, + }), + ], + }); + service = TestBed.inject(JwtHelperService); + }); + + it('should return null when tokenGetter returns null', () => { + tokenGetter.and.returnValue(null); + + expect(service.decodeToken()).toBeNull(); + }); + + it('should throw an error when token contains less than 2 dots', () => { + tokenGetter.and.returnValue('a.b'); + + expect(() => service.decodeToken()).toThrow(); + }); + + it('should throw an error when token contains more than 2 dots', () => { + tokenGetter.and.returnValue('a.b.c.d'); + + expect(() => service.decodeToken()).toThrow(); + }); +}); + +describe('Example HttpService: with a promise based tokken getter', () => { + let service: JwtHelperService; + const tokenGetter = jasmine.createSpy('tokenGetter'); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + JwtModule.forRoot({ + config: { + tokenGetter: tokenGetter, + allowedDomains: ['example-1.com', 'example-2.com', 'example-3.com'], + }, + }), + ], + }); + service = TestBed.inject(JwtHelperService); + }); + + it('should return null when tokenGetter returns null', async () => { + tokenGetter.and.resolveTo(null); + + await expectAsync(service.decodeToken()).toBeResolvedTo(null); + }); + + it('should throw an error when token contains less than 2 dots', async () => { + tokenGetter.and.resolveTo('a.b'); + + await expectAsync(service.decodeToken()).toBeRejected(); + }); + + it('should throw an error when token contains more than 2 dots', async () => { + tokenGetter.and.resolveTo('a.b.c.d'); + + await expectAsync(service.decodeToken()).toBeRejected(); + }); + + it('should return the token when tokenGetter returns a valid JWT', async () => { + tokenGetter.and.resolveTo('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2NjY2ODU4NjAsImV4cCI6MTY5ODIyMTg2MCwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.lXrRPRZ8VNUpwBsT9fLPPO0p0BotQle4siItqg4LqLQ'); + + await expectAsync(service.decodeToken()).toBeResolvedTo(jasmine.anything()); + }); +}); + diff --git a/projects/angular-jwt/src/lib/jwthelper.service.ts b/projects/angular-jwt/src/lib/jwthelper.service.ts index c206e3ec..535f5555 100644 --- a/projects/angular-jwt/src/lib/jwthelper.service.ts +++ b/projects/angular-jwt/src/lib/jwthelper.service.ts @@ -6,7 +6,7 @@ import { JWT_OPTIONS } from './jwtoptions.token'; @Injectable() export class JwtHelperService { - tokenGetter: () => string; + tokenGetter: () => string | Promise; constructor(@Inject(JWT_OPTIONS) config = null) { this.tokenGetter = (config && config.tokenGetter) || function () {}; @@ -77,7 +77,17 @@ export class JwtHelperService { ); } - public decodeToken(token: string = this.tokenGetter()): T { + public decodeToken(token: string = null): T | Promise { + const _token = token || this.tokenGetter(); + + if (_token instanceof Promise) { + return _token.then(t => this._decodeToken(t)); + } + + return this._decodeToken(_token); + } + + private _decodeToken(token: string) { if (!token || token === '') { return null; } @@ -99,8 +109,19 @@ export class JwtHelperService { } public getTokenExpirationDate( - token: string = this.tokenGetter() - ): Date | null { + token: string = null + ): Date | null | Promise { + + const _token = token || this.tokenGetter(); + + if (_token instanceof Promise) { + return _token.then(t => this._getTokenExpirationDate(t)); + } + + return this._getTokenExpirationDate(_token); + } + + private _getTokenExpirationDate(token: string) { let decoded: any; decoded = this.decodeToken(token); @@ -115,7 +136,20 @@ export class JwtHelperService { } public isTokenExpired( - token: string = this.tokenGetter(), + token: string = null, + offsetSeconds?: number + ): boolean | Promise { + const _token = token || this.tokenGetter(); + + if (_token instanceof Promise) { + return _token.then(t => this._isTokenExpired(t, offsetSeconds)); + } + + return this._isTokenExpired(_token, offsetSeconds); + } + + public _isTokenExpired( + token: string, offsetSeconds?: number ): boolean { if (!token || token === '') { diff --git a/src/app/services/example-http.service.spec.ts b/src/app/services/example-http.service.spec.ts index 8d38e365..3ee814c0 100644 --- a/src/app/services/example-http.service.spec.ts +++ b/src/app/services/example-http.service.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { fakeAsync, flush, TestBed } from '@angular/core/testing'; import { ExampleHttpService } from './example-http.service'; import { HttpClientTestingModule, @@ -22,6 +22,89 @@ export function tokenGetterWithRequest(request) { return 'TEST_TOKEN'; } +export function tokenGetterWithPromise() { + return Promise.resolve('TEST_TOKEN'); +} + +describe('Example HttpService: with promise based tokken getter', () => { + let service: ExampleHttpService; + let httpMock: HttpTestingController; + + const validRoutes = [ + `/assets/example-resource.json`, + `http://allowed.com/api/`, + `http://allowed.com/api/test`, + `http://allowed.com:443/api/test`, + `http://allowed-regex.com/api/`, + `https://allowed-regex.com/api/`, + `http://localhost:3000`, + `http://localhost:3000/api`, + ]; + const invalidRoutes = [ + `http://allowed.com/api/disallowed`, + `http://allowed.com/api/disallowed-protocol`, + `http://allowed.com:80/api/disallowed-protocol`, + `http://allowed.com/api/disallowed-regex`, + `http://allowed-regex.com/api/disallowed-regex`, + `http://foo.com/bar`, + 'http://localhost/api', + 'http://localhost:4000/api', + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + JwtModule.forRoot({ + config: { + tokenGetter: tokenGetterWithPromise, + allowedDomains: ['allowed.com', /allowed-regex*/, 'localhost:3000'], + disallowedRoutes: [ + 'http://allowed.com/api/disallowed-protocol', + '//allowed.com/api/disallowed', + /disallowed-regex*/, + ], + }, + }), + ], + }); + service = TestBed.get(ExampleHttpService); + httpMock = TestBed.get(HttpTestingController); + }); + + it('should add Authorisation header', () => { + expect(service).toBeTruthy(); + }); + + validRoutes.forEach((route) => + it(`should set the correct auth token for a allowed domain: ${route}`, fakeAsync(() => { + service.testRequest(route).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + flush(); + const httpRequest = httpMock.expectOne(route); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(true); + expect(httpRequest.request.headers.get('Authorization')).toEqual( + `Bearer TEST_TOKEN` + ); + })) + ); + + invalidRoutes.forEach((route) => + it(`should not set the auth token for a disallowed route: ${route}`, fakeAsync(() => { + service.testRequest(route).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + flush(); + const httpRequest = httpMock.expectOne(route); + expect(httpRequest.request.headers.has('Authorization')).toEqual(false); + }) + )); +}); + describe('Example HttpService: with simple tokken getter', () => { let service: ExampleHttpService; let httpMock: HttpTestingController;