diff --git a/src/backend/app.js b/src/backend/app.js index e382bcaab3..9c6b7b3428 100644 --- a/src/backend/app.js +++ b/src/backend/app.js @@ -125,6 +125,21 @@ app.delete('/api/auth/logout', function (req, res) { }); }); +app.post('/api/auth/refresh-token', function (req, res) { + var payload = { + id: users[0].id, + email: users[0].email, + role: 'user', + }; + var token = jwt.encode(payload, cfg.jwtSecret); + return res.json({ + data: { + message: 'Successfully refreshed token.', + token: token + } + }); +}); + app.listen(4400, function () { console.log('ngx-admin sample API is running on 4400'); }); diff --git a/src/framework/auth/providers/abstract-auth.provider.ts b/src/framework/auth/providers/abstract-auth.provider.ts index 5a88a5d2cc..8d8058c0bc 100644 --- a/src/framework/auth/providers/abstract-auth.provider.ts +++ b/src/framework/auth/providers/abstract-auth.provider.ts @@ -27,6 +27,8 @@ export abstract class NbAbstractAuthProvider { abstract logout(): Observable; + abstract refreshToken(): Observable; + protected createFailResponse(data?: any): HttpResponse { return new HttpResponse({ body: {}, status: 401 }); } diff --git a/src/framework/auth/providers/dummy-auth.provider.ts b/src/framework/auth/providers/dummy-auth.provider.ts index d63db78402..6ed5eea987 100644 --- a/src/framework/auth/providers/dummy-auth.provider.ts +++ b/src/framework/auth/providers/dummy-auth.provider.ts @@ -54,6 +54,13 @@ export class NbDummyAuthProvider extends NbAbstractAuthProvider { ); } + refreshToken(data?: any): Observable { + return observableOf(this.createDummyResult(data)) + .pipe( + delay(this.getConfigValue('delay')), + ); + } + protected createDummyResult(data?: any): NbAuthResult { if (this.getConfigValue('alwaysFail')) { // TODO we dont call tokenService clear during logout in case result is not success diff --git a/src/framework/auth/providers/email-pass-auth.options.ts b/src/framework/auth/providers/email-pass-auth.options.ts index 5e5ad5a0d7..b8254c6fdb 100644 --- a/src/framework/auth/providers/email-pass-auth.options.ts +++ b/src/framework/auth/providers/email-pass-auth.options.ts @@ -31,6 +31,7 @@ export interface NgEmailPassAuthProviderConfig { requestPass?: boolean | NbEmailPassModuleConfig; resetPass?: boolean | NbEmailPassResetModuleConfig; logout?: boolean | NbEmailPassResetModuleConfig; + refreshToken?: boolean | NbEmailPassModuleConfig; token?: { key?: string; getter?: Function; diff --git a/src/framework/auth/providers/email-pass-auth.provider.ts b/src/framework/auth/providers/email-pass-auth.provider.ts index a7acc113de..0d97486280 100644 --- a/src/framework/auth/providers/email-pass-auth.provider.ts +++ b/src/framework/auth/providers/email-pass-auth.provider.ts @@ -84,6 +84,16 @@ import { getDeepFromObject } from '../helpers'; * defaultErrors: ['Something went wrong, please try again.'], * defaultMessages: ['Your password has been successfully changed.'], * }, + * refreshToken: { + * endpoint: '/api/auth/refresh-token', + * method: 'post', + * redirect: { + * success: null, + * failure: null, + * }, + * defaultErrors: ['Something went wrong, please try again.'], + * defaultMessages: ['Your token has been successfully refreshed.'], + * }, * token: { * key: 'data.token', * getter: (module: string, res: HttpResponse) => getDeepFromObject(res.body, @@ -170,6 +180,16 @@ export class NbEmailPassAuthProvider extends NbAbstractAuthProvider { defaultErrors: ['Something went wrong, please try again.'], defaultMessages: ['Your password has been successfully changed.'], }, + refreshToken: { + endpoint: 'refresh-token', + method: 'post', + redirect: { + success: null, + failure: null, + }, + defaultErrors: ['Something went wrong, please try again.'], + defaultMessages: ['Your token has been successfully refreshed.'], + }, token: { key: 'data.token', getter: (module: string, res: HttpResponse) => getDeepFromObject(res.body, @@ -403,6 +423,48 @@ export class NbEmailPassAuthProvider extends NbAbstractAuthProvider { ); } + refreshToken(data?: any): Observable { + const method = this.getConfigValue('refreshToken.method'); + const url = this.getActionEndpoint('refreshToken'); + + return this.http.request(method, url, {body: data, observe: 'response'}) + .pipe( + map((res) => { + if (this.getConfigValue('refreshToken.alwaysFail')) { + throw this.createFailResponse(data); + } + + return res; + }), + this.validateToken('refreshToken'), + map((res) => { + return new NbAuthResult( + true, + res, + this.getConfigValue('refreshToken.redirect.success'), + [], + this.getConfigValue('messages.getter')('refreshToken', res), + this.getConfigValue('token.getter')('refreshToken', res)); + }), + catchError((res) => { + let errors = []; + if (res instanceof HttpErrorResponse) { + errors = this.getConfigValue('errors.getter')('refreshToken', res); + } else { + errors.push('Something went wrong.'); + } + + return observableOf( + new NbAuthResult( + false, + res, + this.getConfigValue('refreshToken.redirect.failure'), + errors, + )); + }), + ); + } + protected validateToken (module: string): any { return map((res) => { const token = this.getConfigValue('token.getter')(module, res); diff --git a/src/framework/auth/providers/email-pass-auth.spec.ts b/src/framework/auth/providers/email-pass-auth.spec.ts index acb341f3f1..e067ba0f88 100644 --- a/src/framework/auth/providers/email-pass-auth.spec.ts +++ b/src/framework/auth/providers/email-pass-auth.spec.ts @@ -250,6 +250,43 @@ describe('email-pass-auth-provider', () => { .flush(successResponse, { status: 401, statusText: 'Unauthorized' }); }); + it('refreshToken success', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken()).toBeUndefined(); // we don't have a token at this stage yet + expect(result.getMessages()).toEqual(successResponse.data.messages); + expect(result.getErrors()).toEqual([]); // no error message, response is success + expect(result.getRawToken()).toEqual(successResponse.data.token); + expect(result.getRedirect()).toEqual(null); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token').flush(successResponse); + }); + + it('refreshToken fail', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(false); + expect(result.isFailure()).toBe(true); + expect(result.getToken()).toBeUndefined(); // we don't have a token at this stage yet + expect(result.getMessages()).toEqual([]); + expect(result.getErrors()).toEqual(successResponse.data.errors); // no error message, response is success + expect(result.getRawToken()).toBeUndefined(); + expect(result.getRedirect()).toEqual(null); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(successResponse, { status: 401, statusText: 'Unauthorized' }); + }); + }); describe('always fail', () => { @@ -271,6 +308,9 @@ describe('email-pass-auth-provider', () => { resetPass: { alwaysFail: true, }, + refreshToken: { + alwaysFail: true, + }, }); }); @@ -344,6 +384,20 @@ describe('email-pass-auth-provider', () => { .flush(successResponse); }); + it('refreshToken fail', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(true); + expect(result.isSuccess()).toBe(false); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(successResponse); + }); + }); describe('custom endpoint', () => { @@ -365,6 +419,9 @@ describe('email-pass-auth-provider', () => { resetPass: { endpoint: 'new', }, + refreshToken: { + endpoint: 'new', + }, }); }); @@ -438,6 +495,20 @@ describe('email-pass-auth-provider', () => { .flush(successResponse); }); + it('refreshToken', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(false); + expect(result.isSuccess()).toBe(true); + + done(); + }); + + httpMock.expectOne('/api/auth/new') + .flush(successResponse); + }); + }); describe('custom base endpoint', () => { @@ -518,6 +589,20 @@ describe('email-pass-auth-provider', () => { .flush(successResponse); }); + it('refreshToken', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(false); + expect(result.isSuccess()).toBe(true); + + done(); + }); + + httpMock.expectOne('/api/auth/custom/refresh-token') + .flush(successResponse); + }); + }); describe('custom method', () => { @@ -539,6 +624,9 @@ describe('email-pass-auth-provider', () => { resetPass: { method: 'get', }, + refreshToken: { + method: 'get', + }, }); }); @@ -612,6 +700,20 @@ describe('email-pass-auth-provider', () => { .flush(successResponse); }); + it('refreshToken', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(false); + expect(result.isSuccess()).toBe(true); + + done(); + }); + + httpMock.expectOne({ method: 'get' }) + .flush(successResponse); + }); + }); describe('custom redirect', () => { @@ -639,6 +741,9 @@ describe('email-pass-auth-provider', () => { resetPass: { redirect, }, + refreshToken: { + redirect, + }, }); }); @@ -772,6 +877,32 @@ describe('email-pass-auth-provider', () => { .flush(successResponse, { status: 401, statusText: 'Unauthorized' }); }); + it('refreshToken success', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.getRedirect()).toBe(redirect.success); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(successResponse); + }); + + it('refreshToken fail', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.getRedirect()).toBe(redirect.failure); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(successResponse, { status: 401, statusText: 'Unauthorized' }); + }); + }); describe('custom message', () => { @@ -799,6 +930,9 @@ describe('email-pass-auth-provider', () => { resetPass: { ...messages, }, + refreshToken: { + ...messages, + }, }); }); @@ -932,6 +1066,32 @@ describe('email-pass-auth-provider', () => { .flush(noMessageResponse, { status: 401, statusText: 'Unauthorized' }); }); + it('refreshToken success', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.getMessages()).toEqual(messages.defaultMessages); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(noMessageResponse); + }); + + it('refreshToken fail', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.getErrors()).toEqual(messages.defaultErrors); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(noMessageResponse, { status: 401, statusText: 'Unauthorized' }); + }); + }); describe('custom token key', () => { @@ -974,6 +1134,21 @@ describe('email-pass-auth-provider', () => { .flush(customResponse); }); + it('refreshToken', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(false); + expect(result.isSuccess()).toBe(true); + expect(result.getRawToken()).toBe('token'); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(customResponse); + }); + }); describe('custom token extractor', () => { @@ -1016,6 +1191,21 @@ describe('email-pass-auth-provider', () => { .flush(customResponse); }); + it('refreshToken', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(false); + expect(result.isSuccess()).toBe(true); + expect(result.getRawToken()).toBe('token'); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(customResponse); + }); + }); describe('custom message key', () => { @@ -1094,6 +1284,36 @@ describe('email-pass-auth-provider', () => { .flush(customResponse, { status: 401, statusText: 'Unauthorized' }); }); + it('refreshToken success', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(false); + expect(result.isSuccess()).toBe(true); + expect(result.getMessages()[0]).toBe('Success message'); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(customResponse); + }); + + it('refreshToken fail', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(true); + expect(result.isSuccess()).toBe(false); + expect(result.getErrors()[0]).toBe('Error message'); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(customResponse, { status: 401, statusText: 'Unauthorized' }); + }); + }); describe('custom message extractor', () => { @@ -1172,6 +1392,36 @@ describe('email-pass-auth-provider', () => { .flush(customResponse, { status: 401, statusText: 'Unauthorized' }); }); + it('refreshToken success', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(false); + expect(result.isSuccess()).toBe(true); + expect(result.getMessages()[0]).toBe('Success message'); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(customResponse); + }); + + it('refreshToken fail', (done: DoneFn) => { + provider.refreshToken() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isFailure()).toBe(true); + expect(result.isSuccess()).toBe(false); + expect(result.getErrors()[0]).toBe('Error message'); + + done(); + }); + + httpMock.expectOne('/api/auth/refresh-token') + .flush(customResponse, { status: 401, statusText: 'Unauthorized' }); + }); + }); }); diff --git a/src/framework/auth/services/auth.service.ts b/src/framework/auth/services/auth.service.ts index 4aa3d65031..be332f6c86 100644 --- a/src/framework/auth/services/auth.service.ts +++ b/src/framework/auth/services/auth.service.ts @@ -153,6 +153,42 @@ export class NbAuthService { return this.getProvider(provider).resetPassword(data); } + /** + * Sends a refresh token request + * Stores received token in the token storage + * + * Example: + * authenticate('email', {email: 'email@example.com', password: 'test'}) + * + * @param {string} provider + * @param data + * @returns {Observable} + */ + refreshToken(provider: string, data?: any): Observable { + return this.getProvider(provider).refreshToken() + .pipe( + switchMap((result: NbAuthResult) => { + return this.processResultToken(result); + }), + ); + } + + /** + * Gets the selected provider + * + * Example: + * getProvider('email') + * + * @param {string} provider + * @returns {NbAbstractAuthProvider} + */ + protected getProvider(provider: string): NbAbstractAuthProvider { + if (!this.providers[provider]) { + throw new TypeError(`Nb auth provider '${provider}' is not registered`); + } + return this.injector.get(this.providers[provider].service); + } + private processResultToken(result: NbAuthResult) { if (result.isSuccess() && result.getRawToken()) { return this.tokenService.setRaw(result.getRawToken()) @@ -167,12 +203,4 @@ export class NbAuthService { return observableOf(result); } - - private getProvider(provider: string): NbAbstractAuthProvider { - if (!this.providers[provider]) { - throw new TypeError(`Nb auth provider '${provider}' is not registered`); - } - - return this.injector.get(this.providers[provider].service); - } } diff --git a/src/framework/auth/services/auth.spec.ts b/src/framework/auth/services/auth.spec.ts index 5a3e93c911..081e936953 100644 --- a/src/framework/auth/services/auth.spec.ts +++ b/src/framework/auth/services/auth.spec.ts @@ -63,6 +63,13 @@ describe('auth-service', () => { [], ['Successfully requested password.']); + const successRefreshTokenResult = new NbAuthResult(true, + resp200, + null, + [], + ['Successfully refreshed token.'], + testTokenValue); + beforeEach(() => { TestBed.configureTestingModule({ providers: [ @@ -407,4 +414,61 @@ describe('auth-service', () => { }) }, ); + + it('refreshToken failed', (done) => { + const spy = spyOn(dummyAuthProvider, 'refreshToken') + .and + .returnValue(observableOf(failResult) + .pipe( + delay(1000), + )); + + authService.refreshToken('dummy').subscribe((authRes: NbAuthResult) => { + expect(spy).toHaveBeenCalled(); + expect(authRes.isFailure()).toBeTruthy(); + expect(authRes.isSuccess()).toBeFalsy(); + expect(authRes.getMessages()).toEqual([]); + expect(authRes.getErrors()).toEqual(['Something went wrong.']); + expect(authRes.getRedirect()).toBeNull(); + expect(authRes.getRawToken()).toBeUndefined(); + expect(authRes.getToken()).toBeUndefined(); + expect(authRes.getResponse()).toEqual(resp401); + done(); + }) + }, + ); + + it('refreshToken succeed', (done) => { + const providerSpy = spyOn(dummyAuthProvider, 'refreshToken') + .and + .returnValue(observableOf(successRefreshTokenResult) + .pipe( + delay(1000), + )); + + const tokenServiceSetSpy = spyOn(tokenService, 'setRaw') + .and + .returnValue(observableOf(null)); + + const tokenServiceGetSpy = spyOn(tokenService, 'get') + .and + .returnValue(observableOf(replacedToken)); + + authService.refreshToken('dummy').subscribe((authRes: NbAuthResult) => { + expect(providerSpy).toHaveBeenCalled(); + expect(tokenServiceSetSpy).toHaveBeenCalled(); + expect(tokenServiceGetSpy).toHaveBeenCalled(); + + expect(authRes.isFailure()).toBeFalsy(); + expect(authRes.isSuccess()).toBeTruthy(); + expect(authRes.getMessages()).toEqual(['Successfully refreshed token.']); + expect(authRes.getErrors()).toEqual([]); + expect(authRes.getRedirect()).toEqual(null); + expect(authRes.getRawToken()).toEqual(replacedToken.getValue()); + expect(authRes.getToken()).toEqual(replacedToken); + expect(authRes.getResponse()).toEqual(resp200); + done(); + }) + }, + ); });