Skip to content

Commit

Permalink
Support promise based tokenGetter in JwtHelperService (#748)
Browse files Browse the repository at this point in the history
* Support promise based tokenGetter in JwtHelperService

* Revert doublequotes

* Fix lint issues

* Fix tests

* fix tests
  • Loading branch information
frederikprijck committed Dec 13, 2022
1 parent c5c008f commit c245511
Show file tree
Hide file tree
Showing 4 changed files with 241 additions and 27 deletions.
51 changes: 30 additions & 21 deletions projects/angular-jwt/src/lib/jwt.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(input: T | Promise<T>) => {
if (input instanceof Promise) {
return defer(() => input);
}
return of(input);
};
@Injectable()
export class JwtInterceptor implements HttpInterceptor {
tokenGetter: (
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
})
);
}
}
88 changes: 88 additions & 0 deletions projects/angular-jwt/src/lib/jwthelper.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});

44 changes: 39 additions & 5 deletions projects/angular-jwt/src/lib/jwthelper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { JWT_OPTIONS } from './jwtoptions.token';

@Injectable()
export class JwtHelperService {
tokenGetter: () => string;
tokenGetter: () => string | Promise<string>;

constructor(@Inject(JWT_OPTIONS) config = null) {
this.tokenGetter = (config && config.tokenGetter) || function () {};
Expand Down Expand Up @@ -77,7 +77,17 @@ export class JwtHelperService {
);
}

public decodeToken<T = any>(token: string = this.tokenGetter()): T {
public decodeToken<T = any>(token: string = null): T | Promise<T> {
const _token = token || this.tokenGetter();

This comment has been minimized.

Copy link
@BloamBla

BloamBla Dec 19, 2022

Leads to error
'T | Promise<T>' is not assignable to type 'T'.

This broke my project. Thanks for minor update :(

This comment has been minimized.

Copy link
@frederikprijck

frederikprijck Dec 20, 2022

Author Member

Sorry about that, I elaborated in #757 (comment). Even thought this is a breaking change, the API never worked the way it claimed it worked. So for some people, the previous version didnt even work for them, therefore we updated this in a patch version.

It might be breaking you, but it should only be a type change. If you know you are using a sync tokenGetter, you could just use as T if you need to, or properly check if it's a promise or not.

This comment has been minimized.

Copy link
@BloamBla

BloamBla Dec 20, 2022

Actually, it doesn't need any changes if you fix version as "~5.0.2".

Temporary solution, but it works, for some time.


if (_token instanceof Promise) {
return _token.then(t => this._decodeToken(t));
}

return this._decodeToken(_token);
}

private _decodeToken(token: string) {
if (!token || token === '') {
return null;
}
Expand All @@ -99,8 +109,19 @@ export class JwtHelperService {
}

public getTokenExpirationDate(
token: string = this.tokenGetter()
): Date | null {
token: string = null
): Date | null | Promise<Date> {

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);

Expand All @@ -115,7 +136,20 @@ export class JwtHelperService {
}

public isTokenExpired(
token: string = this.tokenGetter(),
token: string = null,
offsetSeconds?: number
): boolean | Promise<boolean> {
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 === '') {
Expand Down
85 changes: 84 additions & 1 deletion src/app/services/example-http.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down

0 comments on commit c245511

Please sign in to comment.