diff --git a/src/framework/auth/strategies/oauth2/oauth2-strategy.options.ts b/src/framework/auth/strategies/oauth2/oauth2-strategy.options.ts index fbe14bfe7f..b7c2c9813f 100644 --- a/src/framework/auth/strategies/oauth2/oauth2-strategy.options.ts +++ b/src/framework/auth/strategies/oauth2/oauth2-strategy.options.ts @@ -18,11 +18,18 @@ export enum NbOAuth2GrantType { REFRESH_TOKEN = 'refresh_token', } +export enum NbOAuth2ClientAuthMethod { + NONE = 'none', + BASIC = 'basic', + REQUEST_BODY = 'request-body', +} + export class NbOAuth2AuthStrategyOptions { name: string; baseEndpoint?: string = ''; clientId: string = ''; - clientSecret: string = ''; + clientSecret?: string = ''; + clientAuthMethod?: string = NbOAuth2ClientAuthMethod.NONE; redirect?: { success?: string; failure?: string } = { success: '/', failure: null, diff --git a/src/framework/auth/strategies/oauth2/oauth2-strategy.spec.ts b/src/framework/auth/strategies/oauth2/oauth2-strategy.spec.ts index deba650250..a48186ad07 100644 --- a/src/framework/auth/strategies/oauth2/oauth2-strategy.spec.ts +++ b/src/framework/auth/strategies/oauth2/oauth2-strategy.spec.ts @@ -9,9 +9,8 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { RouterTestingModule } from '@angular/router/testing'; import { ActivatedRoute } from '@angular/router'; import { NB_WINDOW } from '@nebular/theme'; - import { NbOAuth2AuthStrategy } from './oauth2-strategy'; -import { NbOAuth2GrantType, NbOAuth2ResponseType } from './oauth2-strategy.options'; +import { NbOAuth2ClientAuthMethod, NbOAuth2GrantType, NbOAuth2ResponseType } from './oauth2-strategy.options'; import { NbAuthResult, nbAuthCreateToken, NbAuthOAuth2Token } from '../../services'; function createURL(params: any) { @@ -29,6 +28,7 @@ describe('oauth2-auth-strategy', () => { const successMessages = ['You have been successfully authenticated.']; const errorMessages = ['Something went wrong, please try again.']; + const authHeader = 'Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0'; const tokenSuccessResponse = { access_token: '2YotnFZFEjr1zCsicMWpAA', @@ -76,13 +76,14 @@ describe('oauth2-auth-strategy', () => { describe('out of the box: type CODE', () => { + const basicOptions = { + name: 'strategy', + baseEndpoint: 'http://example.com/', + clientId: 'clientId', + } + beforeEach(() => { - strategy.setOptions({ - name: 'strategy', - baseEndpoint: 'http://example.com/', - clientId: 'clientId', - clientSecret: 'clientSecret', - }); + strategy.setOptions(basicOptions); }); it('redirect to auth server', (done: DoneFn) => { @@ -160,9 +161,65 @@ describe('oauth2-auth-strategy', () => { .flush(tokenErrorResponse, { status: 400, statusText: 'Bad Request' }); }); - it('handle refresh token', (done: DoneFn) => { + it('handle refresh token with basic client auth', (done: DoneFn) => { + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.BASIC, + }); + strategy.refreshToken(successToken) + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken().getValue()).toEqual(successToken.getValue()); + expect(result.getToken().getOwnerStrategyName()).toEqual(successToken.getOwnerStrategyName()); + 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.headers.get('Authorization') === authHeader + && req.body['grant_type'] === NbOAuth2GrantType.REFRESH_TOKEN + && req.body['refresh_token'] === successToken.getRefreshToken() + && !req.body['scope'], + ).flush(tokenSuccessResponse); + }); + + it('handle refresh token with requestBody client auth', (done: DoneFn) => { + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.REQUEST_BODY, + }); + strategy.refreshToken(successToken) + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken().getValue()).toEqual(successToken.getValue()); + expect(result.getToken().getOwnerStrategyName()).toEqual(successToken.getOwnerStrategyName()); + 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['client_id'] === strategy.getOption('clientId') + && req.body['client_secret'] === strategy.getOption('clientSecret') + && !req.body['scope'], + ).flush(tokenSuccessResponse); + }); + it('handle refresh token with NO client auth', (done: DoneFn) => { + strategy.setOptions(basicOptions); strategy.refreshToken(successToken) .subscribe((result: NbAuthResult) => { expect(result).toBeTruthy(); @@ -206,16 +263,17 @@ describe('oauth2-auth-strategy', () => { describe('configured: type TOKEN', () => { + const basicOptions = { + name: 'strategy', + baseEndpoint: 'http://example.com/', + clientId: 'clientId', + authorize: { + responseType: NbOAuth2ResponseType.TOKEN, + }, + } + beforeEach(() => { - strategy.setOptions({ - name: 'strategy', - baseEndpoint: 'http://example.com/', - clientId: 'clientId', - clientSecret: 'clientSecret', - authorize: { - responseType: NbOAuth2ResponseType.TOKEN, - }, - }); + strategy.setOptions(basicOptions); }); it('redirect to auth server', (done: DoneFn) => { @@ -269,34 +327,35 @@ describe('oauth2-auth-strategy', () => { describe('configured redirect, redirectUri, scope and additional params: type TOKEN', () => { - beforeEach(() => { - strategy.setOptions({ - name: 'strategy', - 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', + const basicOptions = { + name: 'strategy', + baseEndpoint: 'http://example.com/', + clientId: 'clientId', + redirect: { + success: '/success', + failure: '/failure', + }, + authorize: { + endpoint: 'custom', + redirectUri: 'http://localhost:4200/callback', + scope: 'read', + params: { + display: 'popup', + foo: 'bar', }, - refresh: { - endpoint: 'custom', - scope: 'read', - }, - }); + }, + token: { + endpoint: 'custom', + redirectUri: 'http://localhost:4200/callback', + }, + refresh: { + endpoint: 'custom', + scope: 'read', + }, + } + + beforeEach(() => { + strategy.setOptions(basicOptions); }); it('redirect to auth server', (done: DoneFn) => { @@ -315,7 +374,7 @@ describe('oauth2-auth-strategy', () => { .subscribe(() => {}); }); - it('handle success redirect and sends correct token request', (done: DoneFn) => { + it('handle success redirect and sends correct token request with NO client Auth', (done: DoneFn) => { routeMock.snapshot.queryParams = { code: 'code' }; strategy.authenticate() @@ -340,8 +399,13 @@ describe('oauth2-auth-strategy', () => { ).flush(tokenSuccessResponse); }); - it('handle success redirect back with token request', (done: DoneFn) => { + it('handle success redirect and sends correct token request with BASIC client Auth', (done: DoneFn) => { routeMock.snapshot.queryParams = { code: 'code' }; + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.BASIC, + }) strategy.authenticate() .subscribe((result: NbAuthResult) => { @@ -356,8 +420,45 @@ describe('oauth2-auth-strategy', () => { done(); }); - httpMock.expectOne('http://example.com/custom') - .flush(tokenSuccessResponse); + httpMock.expectOne( + req => req.url === 'http://example.com/custom' + && req.headers.get('Authorization') === authHeader + && 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 and sends correct token request with REQUEST_BODY client Auth', (done: DoneFn) => { + routeMock.snapshot.queryParams = { code: 'code' }; + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.REQUEST_BODY, + }) + + strategy.authenticate() + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken().getValue()).toEqual(successToken.getValue()); + expect(result.getToken().getOwnerStrategyName()).toEqual(successToken.getOwnerStrategyName()); + 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'] === strategy.getOption('clientId') + && req.body['client_secret'] === strategy.getOption('clientSecret') + && req.body['redirect_uri'] === 'http://localhost:4200/callback', + ).flush(tokenSuccessResponse); }); it('handle error redirect back', (done: DoneFn) => { @@ -376,8 +477,36 @@ describe('oauth2-auth-strategy', () => { done(); }); }); + it('handle refresh token with NO client auth', (done: DoneFn) => { + strategy.setOptions(basicOptions); - 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().getValue()).toEqual(successToken.getValue()); + expect(result.getToken().getOwnerStrategyName()).toEqual(successToken.getOwnerStrategyName()); + 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 refresh token with BASIC client auth', (done: DoneFn) => { + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.BASIC, + }); strategy.refreshToken(successToken) .subscribe((result: NbAuthResult) => { @@ -394,12 +523,43 @@ describe('oauth2-auth-strategy', () => { httpMock.expectOne( req => req.url === 'http://example.com/custom' + && req.headers.get('Authorization') === authHeader && req.body['grant_type'] === NbOAuth2GrantType.REFRESH_TOKEN && req.body['refresh_token'] === successToken.getRefreshToken() && req.body['scope'] === 'read', ).flush(tokenSuccessResponse); }); + it('handle refresh token with REQUEST_BODY client auth', (done: DoneFn) => { + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.REQUEST_BODY, + }); + + strategy.refreshToken(successToken) + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken().getValue()).toEqual(successToken.getValue()); + expect(result.getToken().getOwnerStrategyName()).toEqual(successToken.getOwnerStrategyName()); + 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['client_id'] === strategy.getOption('clientId') + && req.body['client_secret'] === strategy.getOption('clientSecret') + && req.body['scope'] === 'read', + ).flush(tokenSuccessResponse); + }); + it('handle error token response', (done: DoneFn) => { routeMock.snapshot.queryParams = { code: 'code' }; @@ -424,22 +584,51 @@ describe('oauth2-auth-strategy', () => { describe('configured: additionnal param: token, grant_type:password', () => { + const basicOptions = { + name: 'strategy', + baseEndpoint: 'http://example.com/', + clientId: 'clientId', + token: { + grantType: NbOAuth2GrantType.PASSWORD, + endpoint: 'token', + }, + } + beforeEach(() => { - strategy.setOptions({ - name: 'strategy', - baseEndpoint: 'http://example.com/', - clientId: 'clientId', - clientSecret: 'clientSecret', - token: { - grantType: NbOAuth2GrantType.PASSWORD, - endpoint: 'token', - }, - }); + strategy.setOptions(basicOptions); }); - it('handle success login', (done: DoneFn) => { + it('handle success login with NO client auth', (done: DoneFn) => { const credentials = { email: 'example@akveo.com', password: '123456' }; + strategy.authenticate(credentials) + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken().getValue()).toEqual(successToken.getValue()); + expect(result.getToken().getOwnerStrategyName()).toEqual(successToken.getOwnerStrategyName()); + 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.PASSWORD + && req.body['email'] === credentials.email + && req.body['password'] === credentials.password, + ).flush(tokenSuccessResponse); + }); + + it('handle success login with BASIC client auth', (done: DoneFn) => { + const credentials = { email: 'example@akveo.com', password: '123456' }; + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.BASIC, + }) strategy.authenticate(credentials) .subscribe((result: NbAuthResult) => { @@ -456,13 +645,46 @@ describe('oauth2-auth-strategy', () => { httpMock.expectOne( req => req.url === 'http://example.com/token' + && req.headers.get('Authorization') === authHeader && req.body['grant_type'] === NbOAuth2GrantType.PASSWORD && req.body['email'] === credentials.email && req.body['password'] === credentials.password, ).flush(tokenSuccessResponse); }); + it('handle success login with REQUEST_BODY client auth', (done: DoneFn) => { + const credentials = { email: 'example@akveo.com', password: '123456' }; + strategy.setOptions({ + ... basicOptions, + clientSecret: 'clientSecret', + clientAuthMethod: NbOAuth2ClientAuthMethod.REQUEST_BODY, + }) + + strategy.authenticate(credentials) + .subscribe((result: NbAuthResult) => { + expect(result).toBeTruthy(); + expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); + expect(result.getToken().getValue()).toEqual(successToken.getValue()); + expect(result.getToken().getOwnerStrategyName()).toEqual(successToken.getOwnerStrategyName()); + 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.PASSWORD + && req.body['email'] === credentials.email + && req.body['password'] === credentials.password + && req.body['client_id'] === strategy.getOption('clientId') + && req.body['client_secret'] === strategy.getOption('clientSecret'), + ).flush(tokenSuccessResponse); + }); + it('handle error login', (done: DoneFn) => { + strategy.setOptions(basicOptions); const credentials = { email: 'example@akveo.com', password: '123456' }; strategy.authenticate(credentials) diff --git a/src/framework/auth/strategies/oauth2/oauth2-strategy.ts b/src/framework/auth/strategies/oauth2/oauth2-strategy.ts index 5e2062fd61..51916c40e4 100644 --- a/src/framework/auth/strategies/oauth2/oauth2-strategy.ts +++ b/src/framework/auth/strategies/oauth2/oauth2-strategy.ts @@ -4,7 +4,7 @@ * 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 { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { Observable, of as observableOf } from 'rxjs'; import { switchMap, map, catchError } from 'rxjs/operators'; @@ -12,10 +12,12 @@ import { NB_WINDOW } from '@nebular/theme'; import { NbAuthStrategy } from '../auth-strategy'; import { NbAuthRefreshableToken, NbAuthResult } from '../../services/'; -import { NbOAuth2AuthStrategyOptions, - NbOAuth2ResponseType, - auth2StrategyOptions, - NbOAuth2GrantType } from './oauth2-strategy.options'; +import { + NbOAuth2AuthStrategyOptions, + NbOAuth2ResponseType, + auth2StrategyOptions, + NbOAuth2GrantType, NbOAuth2ClientAuthMethod, +} from './oauth2-strategy.options'; import { NbAuthStrategyClass } from '../../auth.options'; @@ -41,6 +43,7 @@ import { NbAuthStrategyClass } from '../../auth.options'; * baseEndpoint?: string = ''; * clientId: string = ''; * clientSecret: string = ''; + * clientAuthMethod: string = NbOAuth2ClientAuthMethod.NONE; * redirect?: { success?: string; failure?: string } = { * success: '/', * failure: null, @@ -91,10 +94,8 @@ export class NbOAuth2AuthStrategy extends NbAuthStrategy { return this.getOption('authorize.responseType'); } - protected cleanParams(params: any): any { - Object.entries(params) - .forEach(([key, val]) => !val && delete params[key]); - return params; + get clientAuthMethod() { + return this.getOption('clientAuthMethod'); } protected redirectResultHandlers = { @@ -195,7 +196,7 @@ export class NbOAuth2AuthStrategy extends NbAuthStrategy { refreshToken(token: NbAuthRefreshableToken): Observable { const url = this.getActionEndpoint('refresh'); - return this.http.post(url, this.buildRefreshRequestData(token)) + return this.http.post(url, this.buildRefreshRequestData(token), this.buildAuthHeader()) .pipe( map((res) => { return new NbAuthResult( @@ -213,7 +214,7 @@ export class NbOAuth2AuthStrategy extends NbAuthStrategy { passwordToken(email: string, password: string): Observable { const url = this.getActionEndpoint('token'); - return this.http.post(url, this.buildPasswordRequestData(email, password)) + return this.http.post(url, this.buildPasswordRequestData(email, password), this.buildAuthHeader() ) .pipe( map((res) => { return new NbAuthResult( @@ -239,7 +240,8 @@ export class NbOAuth2AuthStrategy extends NbAuthStrategy { protected requestToken(code: string) { const url = this.getActionEndpoint('token'); - return this.http.post(url, this.buildCodeRequestData(code)) + return this.http.post(url, this.buildCodeRequestData(code), + this.buildAuthHeader()) .pipe( map((res) => { return new NbAuthResult( @@ -261,7 +263,7 @@ export class NbOAuth2AuthStrategy extends NbAuthStrategy { redirect_uri: this.getOption('token.redirectUri'), client_id: this.getOption('clientId'), }; - return this.cleanParams(params); + return this.cleanParams(this.addCredentialsToParams(params)); } protected buildRefreshRequestData(token: NbAuthRefreshableToken): any { @@ -270,7 +272,7 @@ export class NbOAuth2AuthStrategy extends NbAuthStrategy { refresh_token: token.getRefreshToken(), scope: this.getOption('refresh.scope'), }; - return this.cleanParams(params); + return this.cleanParams(this.addCredentialsToParams(params)); } protected buildPasswordRequestData(email: string, password: string ): any { @@ -279,9 +281,48 @@ export class NbOAuth2AuthStrategy extends NbAuthStrategy { email: email, password: password, }; - return this.cleanParams(params); + return this.cleanParams(this.addCredentialsToParams(params)); + } + + protected buildAuthHeader(): any { + if (this.clientAuthMethod === NbOAuth2ClientAuthMethod.BASIC) { + if (this.getOption('clientId') && this.getOption('clientSecret')) { + return { + headers: new HttpHeaders( + { + 'Authorization': 'Basic ' + btoa( + this.getOption('clientId') + ':' + this.getOption('clientSecret')), + }, + ), + }; + } else { + throw Error('For basic client authentication method, please provide both clientId & clientSecret.'); + } + } + } + + protected cleanParams(params: any): any { + Object.entries(params) + .forEach(([key, val]) => !val && delete params[key]); + return params; } + protected addCredentialsToParams(params: any): any { + if (this.clientAuthMethod === NbOAuth2ClientAuthMethod.REQUEST_BODY) { + if (this.getOption('clientId') && this.getOption('clientSecret')) { + return { + ... params, + client_id: this.getOption('clientId'), + client_secret: this.getOption('clientSecret'), + } + } else { + throw Error('For request body client authentication method, please provide both clientId & clientSecret.') + } + } + return params; + } + + protected handleResponseError(res: any): Observable { let errors = []; if (res instanceof HttpErrorResponse) { diff --git a/src/playground/oauth2-password/oauth2-password.module.ts b/src/playground/oauth2-password/oauth2-password.module.ts index 00f2f12285..c650f75d98 100644 --- a/src/playground/oauth2-password/oauth2-password.module.ts +++ b/src/playground/oauth2-password/oauth2-password.module.ts @@ -17,7 +17,7 @@ import { import { NbAuthModule, - NbOAuth2AuthStrategy, NbOAuth2GrantType, + NbOAuth2AuthStrategy, NbOAuth2ClientAuthMethod, NbOAuth2GrantType, } from '@nebular/auth'; import { NbOAuth2PasswordLoginComponent } from './oauth2-password-login.component'; @@ -52,8 +52,9 @@ import { NbAuthOAuth2Token } from '@nebular/auth'; strategies: [ NbOAuth2AuthStrategy.setup({ name: 'password', - clientId: 'test', - clientSecret: 'secret', + clientId: 'Aladdin', + clientSecret: 'open sesame', + clientAuthMethod: NbOAuth2ClientAuthMethod.BASIC, baseEndpoint: 'http://localhost:4400/api/auth/', token: { endpoint: 'token',