diff --git a/docs/articles/auth-intro.md b/docs/articles/auth-intro.md index e4963db5b3..ab4f166ee7 100644 --- a/docs/articles/auth-intro.md +++ b/docs/articles/auth-intro.md @@ -24,6 +24,7 @@ You can use the built-in components or create your custom ones. ## Auth Strategies - `NbDummyAuthStrategy` - simple strategy for testing purposes, could be used to simulate backend responses while API is in the development; - `NbPasswordAuthStrategy` - the most common email/login/password authentication strategy. + - `NbOAuth2AuthStrategy` - the most popular authentication framework that enables applications to obtain limited access to user accounts on an HTTP service.
## Other helper services diff --git a/docs/structure.ts b/docs/structure.ts index 754116b01b..dcc8f0be1f 100644 --- a/docs/structure.ts +++ b/docs/structure.ts @@ -463,6 +463,17 @@ export const structure = [ }, ], }, + { + type: 'page', + name: 'NbOAuth2AuthStrategy', + children: [ + { + type: 'block', + block: 'component', + source: 'NbOAuth2AuthStrategy', + }, + ], + }, { type: 'page', name: 'NbDummyAuthStrategy', diff --git a/src/framework/auth/services/token/token.spec.ts b/src/framework/auth/services/token/token.spec.ts index 08a04e6e70..730630ae02 100644 --- a/src/framework/auth/services/token/token.spec.ts +++ b/src/framework/auth/services/token/token.spec.ts @@ -4,90 +4,182 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ -import { NbAuthJWTToken, NbAuthSimpleToken } from './token'; - - -describe('auth JWT token', () => { - // tslint:disable - const simpleToken = new NbAuthSimpleToken('token'); - const validJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzY290Y2guaW8iLCJleHAiOjI1MTczMTQwNjYxNzUsIm5hbWUiOiJDaHJpcyBTZXZpbGxlamEiLCJhZG1pbiI6dHJ1ZX0=.03f329983b86f7d9a9f5fef85305880101d5e302afafa20154d094b229f75773'); - const emptyJWTToken = new NbAuthJWTToken('..'); - const invalidBase64JWTToken = new NbAuthJWTToken('h%2BHY.h%2BHY.h%2BHY'); - - const invalidJWTToken = new NbAuthJWTToken('.'); - - const noExpJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzY290Y2guaW8iLCJuYW1lIjoiQ2hyaXMgU2V2aWxsZWphIiwiYWRtaW4iOnRydWV9.03f329983b86f7d9a9f5fef85305880101d5e302afafa20154d094b229f75773'); - - const expiredJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzY290Y2guaW8iLCJleHAiOjEzMDA4MTkzODAsIm5hbWUiOiJDaHJpcyBTZXZpbGxlamEiLCJhZG1pbiI6dHJ1ZX0.03f329983b86f7d9a9f5fef85305880101d5e302afafa20154d094b229f75773'); - // tslint:enable - - it('getPayload success', () => { - expect(validJWTToken.getPayload()) - .toEqual(JSON.parse('{"iss":"scotch.io","exp":2517314066175,"name":"Chris Sevilleja","admin":true}')); - }); - - it('getPayload, not valid JWT token, must consist of three parts', () => { - expect(() => { - invalidJWTToken.getPayload(); - }) - .toThrow(new Error( - `The token ${invalidJWTToken.getValue()} is not valid JWT token and must consist of three parts.`)); - }); - - it('getPayload, not valid JWT token, cannot be decoded', () => { - expect(() => { - emptyJWTToken.getPayload(); - }) - .toThrow(new Error( - `The token ${emptyJWTToken.getValue()} is not valid JWT token and cannot be decoded.`)); - }); - - it('getPayload, not valid base64 in JWT token, cannot be decoded', () => { - expect(() => { - invalidBase64JWTToken.getPayload(); - }) - .toThrow(new Error( - `The token ${invalidBase64JWTToken.getValue()} is not valid JWT token and cannot be parsed.`)); - }); - - it('getTokenExpDate success', () => { - const date = new Date(0); - date.setUTCSeconds(2517314066175); - expect(validJWTToken.getTokenExpDate()).toEqual(date); - }); - - it('getTokenExpDate is empty', () => { - expect(noExpJWTToken.getTokenExpDate()).toBeNull(); - }); - - it('no exp date token is valid', () => { - expect(noExpJWTToken.isValid()).toEqual(true); - }); - - it('isValid success', () => { - expect(validJWTToken.isValid()).toEqual(true); - }); - - it('isValid fail', () => { - // without token - expect(new NbAuthJWTToken('').isValid()).toBeFalsy(); - - // expired date - expect(expiredJWTToken.isValid()).toBeFalsy(); - }); - - it('NbAuthJWTToken name', () => { - // without token - expect(NbAuthJWTToken.NAME).toEqual(validJWTToken.getName()); - }); - - it('NbAuthSimpleToken name', () => { - // without token - expect(NbAuthSimpleToken.NAME).toEqual(simpleToken.getName()); +import { NbAuthOAuth2Token, NbAuthJWTToken, NbAuthSimpleToken } from './token'; + + +describe('auth token', () => { + describe('NbAuthJWTToken', () => { + // tslint:disable + const simpleToken = new NbAuthSimpleToken('token'); + const validJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzY290Y2guaW8iLCJleHAiOjI1MTczMTQwNjYxNzUsIm5hbWUiOiJDaHJpcyBTZXZpbGxlamEiLCJhZG1pbiI6dHJ1ZX0=.03f329983b86f7d9a9f5fef85305880101d5e302afafa20154d094b229f75773'); + const emptyJWTToken = new NbAuthJWTToken('..'); + const invalidBase64JWTToken = new NbAuthJWTToken('h%2BHY.h%2BHY.h%2BHY'); + + const invalidJWTToken = new NbAuthJWTToken('.'); + + const noExpJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzY290Y2guaW8iLCJuYW1lIjoiQ2hyaXMgU2V2aWxsZWphIiwiYWRtaW4iOnRydWV9.03f329983b86f7d9a9f5fef85305880101d5e302afafa20154d094b229f75773'); + + const expiredJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzY290Y2guaW8iLCJleHAiOjEzMDA4MTkzODAsIm5hbWUiOiJDaHJpcyBTZXZpbGxlamEiLCJhZG1pbiI6dHJ1ZX0.03f329983b86f7d9a9f5fef85305880101d5e302afafa20154d094b229f75773'); + // tslint:enable + + it('getPayload success', () => { + expect(validJWTToken.getPayload()) + .toEqual(JSON.parse('{"iss":"scotch.io","exp":2517314066175,"name":"Chris Sevilleja","admin":true}')); + }); + + it('getPayload, not valid JWT token, must consist of three parts', () => { + expect(() => { + invalidJWTToken.getPayload(); + }) + .toThrow(new Error( + `The token ${invalidJWTToken.getValue()} is not valid JWT token and must consist of three parts.`)); + }); + + it('getPayload, not valid JWT token, cannot be decoded', () => { + expect(() => { + emptyJWTToken.getPayload(); + }) + .toThrow(new Error( + `The token ${emptyJWTToken.getValue()} is not valid JWT token and cannot be decoded.`)); + }); + + it('getPayload, not valid base64 in JWT token, cannot be decoded', () => { + expect(() => { + invalidBase64JWTToken.getPayload(); + }) + .toThrow(new Error( + `The token ${invalidBase64JWTToken.getValue()} is not valid JWT token and cannot be parsed.`)); + }); + + it('getTokenExpDate success', () => { + const date = new Date(0); + date.setUTCSeconds(2517314066175); + expect(validJWTToken.getTokenExpDate()).toEqual(date); + }); + + it('getTokenExpDate is empty', () => { + expect(noExpJWTToken.getTokenExpDate()).toBeNull(); + }); + + it('no exp date token is valid', () => { + expect(noExpJWTToken.isValid()).toEqual(true); + }); + + it('isValid success', () => { + expect(validJWTToken.isValid()).toEqual(true); + }); + + it('isValid fail', () => { + // without token + expect(new NbAuthJWTToken('').isValid()).toBeFalsy(); + + // expired date + expect(expiredJWTToken.isValid()).toBeFalsy(); + }); + + it('NbAuthJWTToken name', () => { + // without token + expect(NbAuthJWTToken.NAME).toEqual(validJWTToken.getName()); + }); + + it('NbAuthSimpleToken name', () => { + // without token + expect(NbAuthSimpleToken.NAME).toEqual(simpleToken.getName()); + }); + + it('NbAuthSimpleToken has payload', () => { + // without token + expect(simpleToken.getPayload()).toEqual(null); + }); + + it('getPayload success', () => { + expect(validJWTToken.getPayload()) + .toEqual(JSON.parse('{"iss":"scotch.io","exp":2517314066175,"name":"Chris Sevilleja","admin":true}')); + }); + + it('NbAuthJWTToken name', () => { + // without token + expect(NbAuthJWTToken.NAME).toEqual(validJWTToken.getName()); + }); + + it('NbAuthSimpleToken name', () => { + // without token + expect(NbAuthSimpleToken.NAME).toEqual(simpleToken.getName()); + }); + + it('NbAuthSimpleToken has payload', () => { + // without token + expect(simpleToken.getPayload()).toEqual(null); + }); }); - it('NbAuthSimpleToken has payload', () => { - // without token - expect(simpleToken.getPayload()).toEqual(null); + describe('NbAuthOAuth2Token', () => { + + const token = { + access_token: '2YotnFZFEjr1zCsicMWpAA', + expires_in: 3600, + refresh_token: 'tGzv3JOkF0XG5Qx2TlKWIA', + token_type: 'bearer', + example_parameter: 'example_value', + }; + + const validToken = new NbAuthOAuth2Token(token); + const emptyToken = new NbAuthOAuth2Token({}); + + const noExpToken = new NbAuthOAuth2Token({ + access_token: '2YotnFZFEjr1zCsicMWpAA', + refresh_token: 'tGzv3JOkF0XG5Qx2TlKWIA', + example_parameter: 'example_value', + }); + + it('getPayload success', () => { + expect(validToken.getPayload()).toEqual(token); + }); + + it('getPayload, not valid token, cannot be decoded', () => { + expect(() => { + emptyToken.getPayload(); + }) + .toThrow(new Error( + `Cannot extract payload from an empty token.`)); + }); + + it('getTokenExpDate success', () => { + const date = new Date(); + date.setUTCSeconds(date.getUTCSeconds() + 3600); + expect(validToken.getTokenExpDate().getFullYear()).toEqual(date.getFullYear()); + expect(validToken.getTokenExpDate().getDate()).toEqual(date.getDate()); + expect(validToken.getTokenExpDate().getMonth()).toEqual(date.getMonth()); + expect(validToken.getTokenExpDate().getMinutes()).toEqual(date.getMinutes()); + expect(validToken.getTokenExpDate().getSeconds()).toEqual(date.getSeconds()); + }); + + it('getTokenExpDate is empty', () => { + expect(noExpToken.getTokenExpDate()).toBeNull(); + }); + + it('toString is json', () => { + expect(String(validToken)).toEqual(JSON.stringify(token)); + }); + + it('getTokenExpDate is empty', () => { + expect(validToken.getType()).toEqual(token.token_type); + }); + + it('getTokenExpDate is empty', () => { + expect(noExpToken.getRefreshToken()).toEqual(token.refresh_token); + }); + + it('no exp date token is valid', () => { + expect(noExpToken.isValid()).toEqual(true); + }); + + it('isValid success', () => { + expect(validToken.isValid()).toEqual(true); + }); + + it('name', () => { + expect(NbAuthOAuth2Token.NAME).toEqual(validToken.getName()); + }); }); }); diff --git a/src/framework/auth/services/token/token.ts b/src/framework/auth/services/token/token.ts index 2a3cffa29a..7f6d6ae5e2 100644 --- a/src/framework/auth/services/token/token.ts +++ b/src/framework/auth/services/token/token.ts @@ -11,12 +11,16 @@ export abstract class NbAuthToken { } } +export interface NbAuthRefreshableToken { + getRefreshToken(): string; +} + export interface NbAuthTokenClass { NAME: string; - new (raw: string): NbAuthToken; + new (raw: any): NbAuthToken; } -export function nbAuthCreateToken(tokenClass: NbAuthTokenClass, token: string) { +export function nbAuthCreateToken(tokenClass: NbAuthTokenClass, token: any) { return new tokenClass(token); } @@ -27,7 +31,7 @@ export class NbAuthSimpleToken extends NbAuthToken { static NAME = 'nb:auth:simple:token'; - constructor(readonly token: string) { + constructor(protected readonly token: any) { super(); } @@ -48,7 +52,7 @@ export class NbAuthSimpleToken extends NbAuthToken { * @returns {boolean} */ isValid(): boolean { - return !!this.token; + return !!this.getValue(); } /** @@ -121,3 +125,92 @@ export class NbAuthJWTToken extends NbAuthSimpleToken { return super.isValid() && (!this.getTokenExpDate() || new Date() < this.getTokenExpDate()); } } + +const prepareOAuth2Token = (data) => { + if (typeof data === 'string') { + try { + return JSON.parse(data); + } catch (e) {} + } + return data; +}; + +/** + * Wrapper for OAuth2 token + */ +export class NbAuthOAuth2Token extends NbAuthSimpleToken { + + static NAME = 'nb:auth:oauth2:token'; + + constructor(protected data: { [key: string]: string|number }|string = {}) { + // we may get it as string when retrieving from a storage + super(prepareOAuth2Token(data)); + } + + /** + * Returns the token value + * @returns string + */ + getValue(): string { + return this.token.access_token; + } + + /** + * Returns the refresh token + * @returns string + */ + getRefreshToken(): string { + return this.token.refresh_token; + } + + /** + * Returns token payload + * @returns any + */ + getPayload(): any { + if (!this.token || !Object.keys(this.token).length) { + throw new Error('Cannot extract payload from an empty token.'); + } + + return this.token; + } + + /** + * Returns the token type + * @returns string + */ + getType(): string { + return this.token.token_type; + } + + /** + * Is data expired + * @returns {boolean} + */ + isValid(): boolean { + return super.isValid() && (!this.getTokenExpDate() || new Date() < this.getTokenExpDate()); + } + + /** + * Returns expiration date + * @returns Date + */ + getTokenExpDate(): Date { + if (!this.token.hasOwnProperty('expires_in')) { + return null; + } + + const date = new Date(); + date.setUTCSeconds(new Date().getUTCSeconds() + Number(this.token.expires_in)); + + return date; + } + + /** + * Convert to string + * @returns {string} + */ + toString(): string { + return JSON.stringify(this.token); + } +} diff --git a/src/framework/auth/strategies/auth-strategy.ts b/src/framework/auth/strategies/auth-strategy.ts index 59d24dab49..2d17246cee 100644 --- a/src/framework/auth/strategies/auth-strategy.ts +++ b/src/framework/auth/strategies/auth-strategy.ts @@ -20,7 +20,7 @@ export abstract class NbAuthStrategy { return getDeepFromObject(this.options, key, null); } - createToken(value: string): NbAuthToken { + createToken(value: any): NbAuthToken { return nbAuthCreateToken(this.getOption('token.class'), value); } @@ -38,7 +38,7 @@ export abstract class NbAuthStrategy { abstract logout(): Observable; - abstract refreshToken(): Observable; + abstract refreshToken(data?: any): Observable; protected createFailResponse(data?: any): HttpResponse { return new HttpResponse({ body: {}, status: 401 }); @@ -47,4 +47,10 @@ export abstract class NbAuthStrategy { protected createSuccessResponse(data?: any): HttpResponse { return new HttpResponse({ body: {}, status: 200 }); } + + protected getActionEndpoint(action: string): string { + const actionEndpoint: string = this.getOption(`${action}.endpoint`); + const baseEndpoint: string = this.getOption('baseEndpoint'); + return baseEndpoint + actionEndpoint; + } } diff --git a/src/framework/auth/strategies/oauth2/oauth2-strategy.options.ts b/src/framework/auth/strategies/oauth2/oauth2-strategy.options.ts new file mode 100644 index 0000000000..8ed8dc5fb4 --- /dev/null +++ b/src/framework/auth/strategies/oauth2/oauth2-strategy.options.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NbAuthOAuth2Token, NbAuthTokenClass } from '../../services'; + +export enum NbOAuth2ResponseType { + CODE = 'code', + TOKEN = 'token', +} + +// TODO: password, client_credentials +export enum NbOAuth2GrantType { + AUTHORIZATION_CODE = 'authorization_code', + REFRESH_TOKEN = 'refresh_token', +} + +export class NbOAuth2AuthStrategyOptions { + name: string; + baseEndpoint?: string = ''; + clientId: string = ''; + clientSecret: string = ''; + redirect?: { success?: string; failure?: string } = { + success: '/', + failure: null, + }; + defaultErrors?: any[] = ['Something went wrong, please try again.']; + defaultMessages?: any[] = ['You have been successfully authenticated.']; + authorize?: { + endpoint?: string; + redirectUri?: string; + responseType?: string; + scope?: string; + state?: string; + params?: { [key: string]: string }; + } = { + endpoint: 'authorize', + responseType: NbOAuth2ResponseType.CODE, + }; + token?: { + endpoint?: string; + grantType?: string; + redirectUri?: string; + class: NbAuthTokenClass, + } = { + endpoint: 'token', + grantType: NbOAuth2GrantType.AUTHORIZATION_CODE, + class: NbAuthOAuth2Token, + }; + refresh?: { + endpoint?: string; + grantType?: string; + scope?: string; + } = { + endpoint: 'token', + grantType: NbOAuth2GrantType.REFRESH_TOKEN, + }; +} + +export const auth2StrategyOptions: NbOAuth2AuthStrategyOptions = new NbOAuth2AuthStrategyOptions(); diff --git a/src/framework/auth/strategies/oauth2/oauth2-strategy.spec.ts b/src/framework/auth/strategies/oauth2/oauth2-strategy.spec.ts new file mode 100644 index 0000000000..12268a404f --- /dev/null +++ b/src/framework/auth/strategies/oauth2/oauth2-strategy.spec.ts @@ -0,0 +1,412 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { async, inject, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute } from '@angular/router'; +import { NB_WINDOW } from '@nebular/theme'; +import { of as observableOf } from 'rxjs'; + +import { NbOAuth2AuthStrategy } from './oauth2-strategy'; +import { NbOAuth2GrantType, NbOAuth2ResponseType } from './oauth2-strategy.options'; +import { NbAuthResult, nbAuthCreateToken, NbAuthOAuth2Token } from '../../services'; + + +describe('oauth2-auth-strategy', () => { + + let strategy: NbOAuth2AuthStrategy; + let httpMock: HttpTestingController; + let routeMock: any; + let windowMock: any; + + const successMessages = ['You have been successfully authenticated.']; + const errorMessages = ['Something went wrong, please try again.']; + + const tokenSuccessResponse = { + access_token: '2YotnFZFEjr1zCsicMWpAA', + expires_in: 3600, + refresh_token: 'tGzv3JOkF0XG5Qx2TlKWIA', + example_parameter: 'example_value', + }; + + const tokenErrorResponse = { + error: 'unauthorized_client', + error_description: 'unauthorized', + error_uri: 'some', + }; + + const successToken = nbAuthCreateToken(NbAuthOAuth2Token, tokenSuccessResponse) as NbAuthOAuth2Token; + + + beforeEach(() => { + windowMock = { location: { href: '' } }; + routeMock = { params: observableOf({}), queryParams: observableOf({}) }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule], + providers: [ + NbOAuth2AuthStrategy, + { provide: ActivatedRoute, useFactory: () => routeMock }, + { provide: NB_WINDOW, useFactory: () => windowMock }, // useValue will clone, we need reference + ], + }); + }); + + beforeEach(async(inject( + [NbOAuth2AuthStrategy, HttpTestingController], + (_strategy, _httpMock) => { + strategy = _strategy; + httpMock = _httpMock; + + strategy.setOptions({}); + }, + ))); + + afterEach(() => { + httpMock.verify(); + }); + + describe('out of the box: type CODE', () => { + + beforeEach(() => { + strategy.setOptions({ + baseEndpoint: 'http://example.com/', + clientId: 'clientId', + clientSecret: 'clientSecret', + }); + }); + + it('redirect to auth server', (done: DoneFn) => { + windowMock.location = { + set href(value: string) { + expect(value).toEqual('http://example.com/authorize?response_type=code&client_id=clientId'); + done(); + }, + }; + + strategy.authenticate() + .subscribe(() => {}); + }); + + it('handle success redirect and sends correct token request', (done: DoneFn) => { + routeMock.queryParams = observableOf({code: 'code'}); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken()).toEqual(successToken); + expect(result.getMessages()).toEqual(successMessages); + expect(result.getErrors()).toEqual([]); // no error message, response is success + expect(result.getRedirect()).toEqual('/'); + done(); + }); + + httpMock.expectOne( + req => req.url === 'http://example.com/token' + && req.body['grant_type'] === NbOAuth2GrantType.AUTHORIZATION_CODE + && req.body['code'] === 'code' + && req.body['client_id'] === 'clientId' + && !req.body['redirect_uri'], + ).flush(tokenSuccessResponse); + }); + + it('handle error redirect back', (done: DoneFn) => { + routeMock.queryParams = observableOf(tokenErrorResponse); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(false); + expect(result.isFailure()).toBe(true); + expect(result.getToken()).toBeNull(); + expect(result.getResponse()).toEqual(tokenErrorResponse); + expect(result.getMessages()).toEqual([]); + expect(result.getErrors()).toEqual(errorMessages); + expect(result.getRedirect()).toEqual(null); + done(); + }); + }); + + it('handle error token response', (done: DoneFn) => { + routeMock.queryParams = observableOf({code: 'code'}); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(false); + expect(result.isFailure()).toBe(true); + expect(result.getToken()).toBeNull(); + expect(result.getResponse().error).toEqual(tokenErrorResponse); + expect(result.getMessages()).toEqual([]); + expect(result.getErrors()).toEqual(errorMessages); + expect(result.getRedirect()).toEqual(null); + done(); + }); + + httpMock.expectOne('http://example.com/token') + .flush(tokenErrorResponse, { status: 400, statusText: 'Bad Request' }); + }); + + it('handle refresh token', (done: DoneFn) => { + + + strategy.refreshToken(successToken) + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken()).toEqual(successToken); + expect(result.getMessages()).toEqual(successMessages); + expect(result.getErrors()).toEqual([]); // no error message, response is success + expect(result.getRedirect()).toEqual('/'); + done(); + }); + + httpMock.expectOne( + req => req.url === 'http://example.com/token' + && req.body['grant_type'] === NbOAuth2GrantType.REFRESH_TOKEN + && req.body['refresh_token'] === successToken.getRefreshToken() + && !req.body['scope'], + ).flush(tokenSuccessResponse); + }); + + it('handle error token refresh response', (done: DoneFn) => { + + strategy.refreshToken(successToken) + .subscribe((result: NbAuthResult) => { + + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(false); + expect(result.isFailure()).toBe(true); + expect(result.getToken()).toBeNull(); // we don't have a token at this stage yet + expect(result.getResponse().error).toEqual(tokenErrorResponse); + expect(result.getMessages()).toEqual([]); + expect(result.getErrors()).toEqual(errorMessages); + expect(result.getRedirect()).toEqual(null); + done(); + }); + + httpMock.expectOne('http://example.com/token') + .flush(tokenErrorResponse, { status: 400, statusText: 'Bad Request' }); + }); + }); + + describe('configured: type TOKEN', () => { + + beforeEach(() => { + strategy.setOptions({ + baseEndpoint: 'http://example.com/', + clientId: 'clientId', + clientSecret: 'clientSecret', + authorize: { + responseType: NbOAuth2ResponseType.TOKEN, + }, + }); + }); + + it('redirect to auth server', (done: DoneFn) => { + windowMock.location = { + set href(value: string) { + expect(value).toEqual('http://example.com/authorize?response_type=token&client_id=clientId'); + done(); + }, + }; + + strategy.authenticate() + .subscribe(() => {}); + }); + + it('handle success redirect back with token', (done: DoneFn) => { + const token = { access_token: 'token', token_type: 'bearer' }; + + routeMock.params = observableOf(token); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken()).toEqual(nbAuthCreateToken(NbAuthOAuth2Token, token)); + expect(result.getMessages()).toEqual(successMessages); + expect(result.getErrors()).toEqual([]); // no error message, response is success + expect(result.getRedirect()).toEqual('/'); + done(); + }); + }); + + it('handle error redirect back', (done: DoneFn) => { + routeMock.params = observableOf(tokenErrorResponse); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(false); + expect(result.isFailure()).toBe(true); + expect(result.getToken()).toBeNull(); // we don't have a token at this stage yet + expect(result.getResponse()).toEqual(tokenErrorResponse); + expect(result.getMessages()).toEqual([]); + expect(result.getErrors()).toEqual(errorMessages); + expect(result.getRedirect()).toEqual(null); + done(); + }); + }); + }); + + describe('configured redirect, redirectUri, scope and additional params: type TOKEN', () => { + + beforeEach(() => { + strategy.setOptions({ + baseEndpoint: 'http://example.com/', + clientId: 'clientId', + clientSecret: 'clientSecret', + redirect: { + success: '/success', + failure: '/failure', + }, + authorize: { + endpoint: 'custom', + redirectUri: 'http://localhost:4200/callback', + scope: 'read', + params: { + display: 'popup', + foo: 'bar', + }, + }, + token: { + endpoint: 'custom', + redirectUri: 'http://localhost:4200/callback', + }, + refresh: { + endpoint: 'custom', + scope: 'read', + }, + }); + }); + + it('redirect to auth server', (done: DoneFn) => { + windowMock.location = { + set href(value: string) { + const baseUrl = 'http://example.com/custom?response_type=code&client_id=clientId&redirect_uri='; + const redirect = encodeURIComponent('http://localhost:4200/callback'); + const url = `${baseUrl}${redirect}&scope=read&display=popup&foo=bar`; + + expect(value).toEqual(url); + done(); + }, + }; + + strategy.authenticate() + .subscribe(() => {}); + }); + + it('handle success redirect and sends correct token request', (done: DoneFn) => { + routeMock.queryParams = observableOf({code: 'code'}); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken()).toEqual(successToken); + expect(result.getMessages()).toEqual(successMessages); + expect(result.getErrors()).toEqual([]); // no error message, response is success + expect(result.getRedirect()).toEqual('/success'); + done(); + }); + + httpMock.expectOne( + req => req.url === 'http://example.com/custom' + && req.body['grant_type'] === NbOAuth2GrantType.AUTHORIZATION_CODE + && req.body['code'] === 'code' + && req.body['client_id'] === 'clientId' + && req.body['redirect_uri'] === 'http://localhost:4200/callback', + ).flush(tokenSuccessResponse); + }); + + it('handle success redirect back with token request', (done: DoneFn) => { + routeMock.queryParams = observableOf({code: 'code'}); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken()).toEqual(successToken); + expect(result.getMessages()).toEqual(successMessages); + expect(result.getErrors()).toEqual([]); // no error message, response is success + expect(result.getRedirect()).toEqual('/success'); + done(); + }); + + httpMock.expectOne('http://example.com/custom') + .flush(tokenSuccessResponse); + }); + + it('handle error redirect back', (done: DoneFn) => { + routeMock.queryParams = observableOf(tokenErrorResponse); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(false); + expect(result.isFailure()).toBe(true); + expect(result.getToken()).toBeNull(); + expect(result.getResponse()).toEqual(tokenErrorResponse); + expect(result.getMessages()).toEqual([]); + expect(result.getErrors()).toEqual(errorMessages); + expect(result.getRedirect()).toEqual('/failure'); + done(); + }); + }); + + it('handle refresh token', (done: DoneFn) => { + + strategy.refreshToken(successToken) + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken()).toEqual(successToken); + expect(result.getMessages()).toEqual(successMessages); + expect(result.getErrors()).toEqual([]); // no error message, response is success + expect(result.getRedirect()).toEqual('/success'); + done(); + }); + + httpMock.expectOne( + req => req.url === 'http://example.com/custom' + && req.body['grant_type'] === NbOAuth2GrantType.REFRESH_TOKEN + && req.body['refresh_token'] === successToken.getRefreshToken() + && req.body['scope'] === 'read', + ).flush(tokenSuccessResponse); + }); + + it('handle error token response', (done: DoneFn) => { + routeMock.queryParams = observableOf({code: 'code'}); + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(false); + expect(result.isFailure()).toBe(true); + expect(result.getToken()).toBeNull(); + expect(result.getResponse().error).toEqual(tokenErrorResponse); + expect(result.getMessages()).toEqual([]); + expect(result.getErrors()).toEqual(errorMessages); + expect(result.getRedirect()).toEqual('/failure'); + done(); + }); + + httpMock.expectOne('http://example.com/custom') + .flush(tokenErrorResponse, { status: 400, statusText: 'Bad Request' }); + }); + }); +}); diff --git a/src/framework/auth/strategies/oauth2/oauth2-strategy.ts b/src/framework/auth/strategies/oauth2/oauth2-strategy.ts new file mode 100644 index 0000000000..f350eaee93 --- /dev/null +++ b/src/framework/auth/strategies/oauth2/oauth2-strategy.ts @@ -0,0 +1,315 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ +import { Inject, Injectable } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, of as observableOf } from 'rxjs'; +import { switchMap, map, catchError } from 'rxjs/operators'; +import { NB_WINDOW } from '@nebular/theme'; + +import { NbAuthStrategy } from '../auth-strategy'; +import { NbAuthRefreshableToken, NbAuthResult } from '../../services/'; +import { NbOAuth2AuthStrategyOptions, NbOAuth2ResponseType, auth2StrategyOptions } from './oauth2-strategy.options'; + + +/** + * OAuth2 authentication strategy. + * + * @example + * + * Strategy settings: + * + * ``` + * + * export enum NbOAuth2ResponseType { + * CODE = 'code', + * TOKEN = 'token', + * } + * + * // TODO: password, client_credentials + * export enum NbOAuth2GrantType { + * AUTHORIZATION_CODE = 'authorization_code', + * REFRESH_TOKEN = 'refresh_token', + * } + * + * export class NbOAuth2AuthStrategyOptions { + * name: string; + * baseEndpoint?: string = ''; + * clientId: string = ''; + * clientSecret: string = ''; + * redirect?: { success?: string; failure?: string } = { + * success: '/', + * failure: null, + * }; + * defaultErrors?: any[] = ['Something went wrong, please try again.']; + * defaultMessages?: any[] = ['You have been successfully authenticated.']; + * authorize?: { + * endpoint?: string; + * redirectUri?: string; + * responseType?: string; + * scope?: string; + * state?: string; + * params?: { [key: string]: string }; + * } = { + * endpoint: 'authorize', + * responseType: NbOAuth2ResponseType.CODE, + * }; + * token?: { + * endpoint?: string; + * grantType?: string; + * redirectUri?: string; + * class: NbAuthTokenClass, + * } = { + * endpoint: 'token', + * grantType: NbOAuth2GrantType.AUTHORIZATION_CODE, + * class: NbAuthOAuth2Token, + * }; + * refresh?: { + * endpoint?: string; + * grantType?: string; + * scope?: string; + * } = { + * endpoint: 'token', + * grantType: NbOAuth2GrantType.REFRESH_TOKEN, + * }; + * } + * ``` + * + */ +@Injectable() +export class NbOAuth2AuthStrategy extends NbAuthStrategy { + + get responseType() { + return this.getOption('authorize.responseType'); + } + + protected redirectResultHandlers = { + [NbOAuth2ResponseType.CODE]: () => { + return this.route.queryParams.pipe( + switchMap((params: any) => { + if (params.code) { + return this.requestToken(params.code) + } + + return observableOf( + new NbAuthResult( + false, + params, + this.getOption('redirect.failure'), + this.getOption('defaultErrors'), + [], + )); + }), + ); + }, + [NbOAuth2ResponseType.TOKEN]: () => { + return this.route.params.pipe( + map((params: any) => { + if (!params.error) { + return new NbAuthResult( + true, + params, + this.getOption('redirect.success'), + [], + this.getOption('defaultMessages'), + this.createToken(params)); + } + + return new NbAuthResult( + false, + params, + this.getOption('redirect.failure'), + this.getOption('defaultErrors'), + [], + ); + }), + ); + }, + }; + + protected redirectResults = { + [NbOAuth2ResponseType.CODE]: () => { + return this.route.queryParams.pipe( + map((params: any) => !!(params && (params.code || params.error))), + ); + }, + [NbOAuth2ResponseType.TOKEN]: () => { + return this.route.params.pipe( + map((params: any) => !!(params && (params.access_token || params.error))), + ); + }, + }; + + protected defaultOptions: NbOAuth2AuthStrategyOptions = auth2StrategyOptions; + + constructor(protected http: HttpClient, + private route: ActivatedRoute, + @Inject(NB_WINDOW) private window: any) { + super(); + } + + authenticate(): Observable { + return this.isRedirectResult() + .pipe( + switchMap((result: boolean) => { + if (!result) { + this.authorizeRedirect(); + return observableOf(null); + } + return this.getAuthorizationResult(); + }), + ); + } + + getAuthorizationResult(): Observable { + const redirectResultHandler = this.redirectResultHandlers[this.responseType]; + if (redirectResultHandler) { + return redirectResultHandler.call(this); + } + + throw new Error(`'${this.responseType}' responseType is not supported, + only 'token' and 'code' are supported now`); + } + + refreshToken(token: NbAuthRefreshableToken): Observable { + const url = this.getActionEndpoint('refresh'); + + return this.http.post(url, this.buildRefreshRequestData(token)) + .pipe( + map((res) => { + return new NbAuthResult( + true, + res, + this.getOption('redirect.success'), + [], + this.getOption('defaultMessages'), + this.createToken(res)); + }), + catchError((res) => { + let errors = []; + if (res instanceof HttpErrorResponse) { + errors = this.getOption('defaultErrors'); + } else { + errors.push('Something went wrong.'); + } + + return observableOf( + new NbAuthResult( + false, + res, + this.getOption('redirect.failure'), + errors, + [], + )); + }), + ); + } + + protected authorizeRedirect() { + this.window.location.href = this.buildRedirectUrl(); + } + + protected isRedirectResult(): Observable { + return this.redirectResults[this.responseType].call(this); + } + + protected requestToken(code: string) { + const url = this.getActionEndpoint('token'); + + return this.http.post(url, this.buildCodeRequestData(code)) + .pipe( + map((res) => { + return new NbAuthResult( + true, + res, + this.getOption('redirect.success'), + [], + this.getOption('defaultMessages'), + this.createToken(res)); + }), + catchError((res) => { + let errors = []; + if (res instanceof HttpErrorResponse) { + errors = this.getOption('defaultErrors'); + } else { + errors.push('Something went wrong.'); + } + + return observableOf( + new NbAuthResult( + false, + res, + this.getOption('redirect.failure'), + errors, + [], + )); + }), + ); + } + + protected buildCodeRequestData(code: string): any { + const params = { + grant_type: this.getOption('token.grantType'), + code: code, + redirect_uri: this.getOption('token.redirectUri'), + client_id: this.getOption('clientId'), + }; + + Object.entries(params) + .forEach(([key, val]) => !val && delete params[key]); + + return params; + } + + protected buildRefreshRequestData(token: NbAuthRefreshableToken): any { + const params = { + grant_type: this.getOption('refresh.grantType'), + refresh_token: token.getRefreshToken(), + scope: this.getOption('refresh.scope'), + }; + + Object.entries(params) + .forEach(([key, val]) => !val && delete params[key]); + + return params; + } + + protected buildRedirectUrl() { + const params = { + response_type: this.getOption('authorize.responseType'), + client_id: this.getOption('clientId'), + redirect_uri: this.getOption('authorize.redirectUri'), + scope: this.getOption('authorize.scope'), + state: this.getOption('authorize.state'), + + ...this.getOption('authorize.params'), + }; + + const endpoint = this.getActionEndpoint('authorize'); + const query = Object.entries(params) + .filter(([key, val]) => !!val) + .map(([key, val]: [string, string]) => `${key}=${encodeURIComponent(val)}`) + .join('&'); + + return `${endpoint}?${query}`; + } + + register(data?: any): Observable { + throw new Error('`register` is not supported by `NbOAuth2AuthStrategy`, use `authenticate`.'); + } + + requestPassword(data?: any): Observable { + throw new Error('`requestPassword` is not supported by `NbOAuth2AuthStrategy`, use `authenticate`.'); + } + + resetPassword(data: any = {}): Observable { + throw new Error('`resetPassword` is not supported by `NbOAuth2AuthStrategy`, use `authenticate`.'); + } + + logout(): Observable { + throw new Error('`logout` is not supported by `NbOAuth2AuthStrategy`, use `authenticate`.'); + } +} diff --git a/src/framework/auth/strategies/password/password-strategy.ts b/src/framework/auth/strategies/password/password-strategy.ts index 86b4e6912f..394278c9c9 100644 --- a/src/framework/auth/strategies/password/password-strategy.ts +++ b/src/framework/auth/strategies/password/password-strategy.ts @@ -419,10 +419,4 @@ export class NbPasswordAuthStrategy extends NbAuthStrategy { return res; }); } - - protected getActionEndpoint(action: string): string { - const actionEndpoint: string = this.getOption(`${action}.endpoint`); - const baseEndpoint: string = this.getOption('baseEndpoint'); - return baseEndpoint + actionEndpoint; - } }