diff --git a/plugins/auth-backend/src/providers/OAuthProvider.test.ts b/plugins/auth-backend/src/providers/OAuthProvider.test.ts new file mode 100644 index 0000000000..308849057b --- /dev/null +++ b/plugins/auth-backend/src/providers/OAuthProvider.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('OAuthProvider', () => { + it('unbreak test runner', () => { + expect(true).toBeTruthy(); + }); +}); diff --git a/plugins/auth-backend/src/providers/OAuthProvider.ts b/plugins/auth-backend/src/providers/OAuthProvider.ts index 81f5ed25a6..822cd5b267 100644 --- a/plugins/auth-backend/src/providers/OAuthProvider.ts +++ b/plugins/auth-backend/src/providers/OAuthProvider.ts @@ -16,9 +16,12 @@ import express, { CookieOptions } from 'express'; import crypto from 'crypto'; -import { AuthProviderRouteHandlers, OAuthProviderHandlers } from './types'; +import { + AuthResponse, + AuthProviderRouteHandlers, + OAuthProviderHandlers, +} from './types'; import { InputError } from '@backstage/backend-common'; -import { postMessageResponse, ensuresXRequestedWith } from './utils'; export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000; export const TEN_MINUTES_MS = 600 * 1000; @@ -86,6 +89,38 @@ export const removeRefreshTokenCookie = ( res.cookie(`${provider}-refresh-token`, '', options); }; +export const postMessageResponse = ( + res: express.Response, + data: AuthResponse, +) => { + const jsonData = JSON.stringify(data); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('X-Frame-Options', 'sameorigin'); + + // TODO: Make target app origin configurable globally + res.end(` + +
+ + + + `); +}; + +export const ensuresXRequestedWith = (req: express.Request) => { + const requiredHeader = req.header('X-Requested-With'); + + if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { + return false; + } + return true; +}; + export class OAuthProvider implements AuthProviderRouteHandlers { private readonly provider: string; private readonly providerHandlers: OAuthProviderHandlers; diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts new file mode 100644 index 0000000000..e977ee877d --- /dev/null +++ b/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts @@ -0,0 +1,214 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import passport from 'passport'; +import { + executeRedirectStrategy, + executeFrameHandlerStrategy, + executeRefreshTokenStrategy, +} from './PassportStrategyHelper'; + +const mockRequest = ({} as unknown) as express.Request; + +describe('PassportStrategyHelper', () => { + class MyCustomRedirectStrategy extends passport.Strategy { + authenticate() { + this.redirect('a', 302); + } + } + + describe('executeRedirectStrategy', () => { + it('should call authenticate and resolve with RedirectInfo', async () => { + const mockStrategy = new MyCustomRedirectStrategy(); + const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate'); + const redirectStrategyPromise = executeRedirectStrategy( + mockRequest, + mockStrategy, + {}, + ); + expect(spyAuthenticate).toBeCalledTimes(1); + await expect(redirectStrategyPromise).resolves.toStrictEqual( + expect.objectContaining({ url: 'a', status: 302 }), + ); + }); + }); + + describe('executeFrameHandlerStrategy', () => { + class MyCustomAuthSuccessStrategy extends passport.Strategy { + authenticate() { + this.success( + { accessToken: 'ACCESS_TOKEN' }, + { refreshToken: 'REFRESH_TOKEN' }, + ); + } + } + class MyCustomAuthErrorStrategy extends passport.Strategy { + authenticate() { + this.error(new Error('MyCustomAuth error')); + } + } + class MyCustomAuthRedirectStrategy extends passport.Strategy { + authenticate() { + this.redirect('URL', 302); + } + } + class MyCustomAuthFailStrategy extends passport.Strategy { + authenticate() { + this.fail('challenge', 302); + } + } + + it('should resolve with user and info on success', async () => { + const mockStrategy = new MyCustomAuthSuccessStrategy(); + const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate'); + const frameHandlerStrategyPromise = executeFrameHandlerStrategy( + mockRequest, + mockStrategy, + ); + expect(spyAuthenticate).toBeCalledTimes(1); + await expect(frameHandlerStrategyPromise).resolves.toStrictEqual( + expect.objectContaining({ + user: { accessToken: 'ACCESS_TOKEN' }, + info: { refreshToken: 'REFRESH_TOKEN' }, + }), + ); + }); + + it('should reject on error', async () => { + const mockStrategy = new MyCustomAuthErrorStrategy(); + const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate'); + const frameHandlerStrategyPromise = executeFrameHandlerStrategy( + mockRequest, + mockStrategy, + ); + expect(spyAuthenticate).toBeCalledTimes(1); + await expect(frameHandlerStrategyPromise).rejects.toThrow( + 'Authentication failed, Error: MyCustomAuth error', + ); + }); + + it('should reject on redirect', async () => { + const mockStrategy = new MyCustomAuthRedirectStrategy(); + const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate'); + const frameHandlerStrategyPromise = executeFrameHandlerStrategy( + mockRequest, + mockStrategy, + ); + expect(spyAuthenticate).toBeCalledTimes(1); + await expect(frameHandlerStrategyPromise).rejects.toThrow( + 'Unexpected redirect', + ); + }); + + it('should reject on fail', async () => { + const mockStrategy = new MyCustomAuthFailStrategy(); + const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate'); + const frameHandlerStrategyPromise = executeFrameHandlerStrategy( + mockRequest, + mockStrategy, + ); + expect(spyAuthenticate).toBeCalledTimes(1); + await expect(frameHandlerStrategyPromise).rejects.toThrow(); + }); + }); + + describe('executeRefreshTokenStrategy', () => { + it('should resolve with a new access token, scope and expiry', async () => { + class MyCustomOAuth2Success { + getOAuthAccessToken( + _refreshToken: string, + _options: any, + callback: Function, + ) { + callback(null, 'ACCESS_TOKEN', 'REFRESH_TOKEN', { + scope: 'a', + expires_in: 10, + }); + } + } + class MyCustomRefreshTokenSuccess extends passport.Strategy { + // @ts-ignore + private _oauth2 = new MyCustomOAuth2Success(); + } + + const mockStrategy = new MyCustomRefreshTokenSuccess(); + const refreshTokenPromise = executeRefreshTokenStrategy( + mockStrategy, + 'REFRESH_TOKEN', + 'a', + ); + await expect(refreshTokenPromise).resolves.toStrictEqual( + expect.objectContaining({ + accessToken: 'ACCESS_TOKEN', + params: expect.objectContaining({ scope: 'a', expires_in: 10 }), + }), + ); + }); + + it('should reject with an error if refresh failed', async () => { + class MyCustomOAuth2Error { + getOAuthAccessToken( + _refreshToken: string, + _options: any, + callback: Function, + ) { + callback(new Error('Unknown error')); + } + } + class MyCustomRefreshTokenSuccess extends passport.Strategy { + // @ts-ignore + private _oauth2 = new MyCustomOAuth2Error(); + } + + const mockStrategy = new MyCustomRefreshTokenSuccess(); + const refreshTokenPromise = executeRefreshTokenStrategy( + mockStrategy, + 'REFRESH_TOKEN', + 'a', + ); + await expect(refreshTokenPromise).rejects.toThrow( + 'Failed to refresh access token Error: Unknown error', + ); + }); + + it('should reject with an error if access token missing in refresh callback', async () => { + class MyCustomOAuth2AccessTokenMissing { + getOAuthAccessToken( + _refreshToken: string, + _options: any, + callback: Function, + ) { + callback(null, ''); + } + } + class MyCustomRefreshTokenSuccess extends passport.Strategy { + // @ts-ignore + private _oauth2 = new MyCustomOAuth2AccessTokenMissing(); + } + + const mockStrategy = new MyCustomRefreshTokenSuccess(); + const refreshTokenPromise = executeRefreshTokenStrategy( + mockStrategy, + 'REFRESH_TOKEN', + 'a', + ); + await expect(refreshTokenPromise).rejects.toThrow( + 'Failed to refresh access token, no access token received', + ); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/utils.ts b/plugins/auth-backend/src/providers/utils.ts deleted file mode 100644 index 83229e55d4..0000000000 --- a/plugins/auth-backend/src/providers/utils.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import { AuthResponse } from './types'; - -export const postMessageResponse = ( - res: express.Response, - data: AuthResponse, -) => { - const jsonData = JSON.stringify(data); - const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); - - res.setHeader('Content-Type', 'text/html'); - res.setHeader('X-Frame-Options', 'sameorigin'); - - // TODO: Make target app origin configurable globally - res.end(` - - - - - - `); -}; - -export const ensuresXRequestedWith = (req: express.Request) => { - const requiredHeader = req.header('X-Requested-With'); - - if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { - return false; - } - return true; -};