From 52af2a8472fd9ef8aa0dff49ec0562c572b18b50 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 16:19:59 +0200 Subject: [PATCH] auth-node: add sendWebMessageResponse Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/flow/index.ts | 20 ++ .../src/flow/sendWebMessageResponse.test.ts | 179 ++++++++++++++++++ .../src/flow/sendWebMessageResponse.ts | 87 +++++++++ plugins/auth-node/src/index.ts | 1 + .../src/oauth/createOAuthHandlers.ts | 6 +- 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 plugins/auth-node/src/flow/index.ts create mode 100644 plugins/auth-node/src/flow/sendWebMessageResponse.test.ts create mode 100644 plugins/auth-node/src/flow/sendWebMessageResponse.ts diff --git a/plugins/auth-node/src/flow/index.ts b/plugins/auth-node/src/flow/index.ts new file mode 100644 index 0000000000..51d91e5d58 --- /dev/null +++ b/plugins/auth-node/src/flow/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +export { + sendWebMessageResponse, + type WebMessageResponse, +} from './sendWebMessageResponse'; diff --git a/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts b/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts new file mode 100644 index 0000000000..fd706159ba --- /dev/null +++ b/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { + safelyEncodeURIComponent, + sendWebMessageResponse, + type WebMessageResponse, +} from './sendWebMessageResponse'; + +describe('oauth helpers', () => { + describe('safelyEncodeURIComponent', () => { + it('encodes all occurrences of single quotes', () => { + expect(safelyEncodeURIComponent("a'ö'b")).toBe('a%27%C3%B6%27b'); + }); + }); + + describe('sendWebMessageResponse', () => { + const appOrigin = 'http://localhost:3000'; + it('should post a message back with payload success', () => { + const mockResponse = { + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + response: { + providerInfo: { + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 10, + scope: 'email', + }, + profile: { + email: 'foo@bar.com', + }, + backstageIdentity: { + token: 'a.b.c', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'a', + }, + }, + }, + }; + const encoded = safelyEncodeURIComponent(JSON.stringify(data)); + + sendWebMessageResponse(mockResponse, appOrigin, data); + expect(mockResponse.setHeader).toHaveBeenCalledTimes(3); + expect(mockResponse.end).toHaveBeenCalledTimes(1); + expect(mockResponse.end).toHaveBeenCalledWith( + expect.stringContaining(encoded), + ); + }); + + it('should post a message back with payload error', () => { + const mockResponse = { + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + error: new Error('Unknown error occurred'), + }; + const encoded = safelyEncodeURIComponent(JSON.stringify(data)); + + sendWebMessageResponse(mockResponse, appOrigin, data); + expect(mockResponse.setHeader).toHaveBeenCalledTimes(3); + expect(mockResponse.end).toHaveBeenCalledTimes(1); + expect(mockResponse.end).toHaveBeenCalledWith( + expect.stringContaining(encoded), + ); + }); + + it('should call postMessage twice but only one of them with target *', () => { + let responseBody = ''; + + const mockResponse = { + end: jest.fn(body => { + responseBody = body; + return this; + }), + setHeader: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + response: { + providerInfo: { + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 10, + scope: 'email', + }, + profile: { + email: 'foo@bar.com', + }, + backstageIdentity: { + token: 'a.b.c', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'a', + }, + }, + }, + }; + sendWebMessageResponse(mockResponse, appOrigin, data); + expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2); + expect( + responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g), + ).toHaveLength(1); + + const errData: WebMessageResponse = { + type: 'authorization_response', + error: new Error('Unknown error occurred'), + }; + sendWebMessageResponse(mockResponse, appOrigin, errData); + expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2); + expect( + responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g), + ).toHaveLength(1); + }); + + it('handles single quotes and unicode chars safely', () => { + const mockResponse = { + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + response: { + providerInfo: { + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 10, + scope: 'email', + }, + profile: { + email: 'foo@bar.com', + displayName: "Adam l'Hôpital", + }, + backstageIdentity: { + token: 'a.b.c', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'a', + }, + }, + }, + }; + + sendWebMessageResponse(mockResponse, appOrigin, data); + expect(mockResponse.setHeader).toHaveBeenCalledTimes(3); + expect(mockResponse.end).toHaveBeenCalledTimes(1); + expect(mockResponse.end).toHaveBeenCalledWith( + expect.stringContaining('Adam%20l%27H%C3%B4pital'), + ); + }); + }); +}); diff --git a/plugins/auth-node/src/flow/sendWebMessageResponse.ts b/plugins/auth-node/src/flow/sendWebMessageResponse.ts new file mode 100644 index 0000000000..7bb658bd11 --- /dev/null +++ b/plugins/auth-node/src/flow/sendWebMessageResponse.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Response } from 'express'; +import crypto from 'crypto'; +import { ClientAuthResponse } from '../types'; + +/** + * Payload sent as a post message after the auth request is complete. + * If successful then has a valid payload with Auth information else contains an error. + * + * @public + */ +export type WebMessageResponse = + | { + type: 'authorization_response'; + response: ClientAuthResponse; + } + | { + type: 'authorization_response'; + error: Error; + }; + +/** @internal */ +export function safelyEncodeURIComponent(value: string): string { + // Note the g at the end of the regex; all occurrences of single quotes must + // be replaced, which encodeURIComponent does not do itself by default + return encodeURIComponent(value).replace(/'/g, '%27'); +} + +/** @public */ +export function sendWebMessageResponse( + res: Response, + appOrigin: string, + response: WebMessageResponse, +): void { + const jsonData = JSON.stringify(response); + const base64Data = safelyEncodeURIComponent(jsonData); + const base64Origin = safelyEncodeURIComponent(appOrigin); + + // NOTE: It is absolutely imperative that we use the safe encoder above, to + // be sure that the js code below does not allow the injection of malicious + // data. + + // TODO: Make target app origin configurable globally + + // + // postMessage fails silently if the targetOrigin is disallowed. + // So 2 postMessages are sent from the popup to the parent window. + // First, the origin being used to post the actual authorization response is + // shared with the parent window with a postMessage with targetOrigin '*'. + // Second, the actual authorization response is sent with the app origin + // as the targetOrigin. + // If the first message was received but the actual auth response was + // never received, the event listener can conclude that targetOrigin + // was disallowed, indicating potential misconfiguration. + // + const script = ` + var authResponse = decodeURIComponent('${base64Data}'); + var origin = decodeURIComponent('${base64Origin}'); + var originInfo = {'type': 'config_info', 'targetOrigin': origin}; + (window.opener || window.parent).postMessage(originInfo, '*'); + (window.opener || window.parent).postMessage(JSON.parse(authResponse), origin); + setTimeout(() => { + window.close(); + }, 100); // same as the interval of the core-app-api lib/loginPopup.ts (to address race conditions) + `; + const hash = crypto.createHash('sha256').update(script).digest('base64'); + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('X-Frame-Options', 'sameorigin'); + res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`); + res.end(``); +} diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index a1f4ef3206..7740651d44 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './flow'; export * from './identity'; export * from './passport'; export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; diff --git a/plugins/auth-node/src/oauth/createOAuthHandlers.ts b/plugins/auth-node/src/oauth/createOAuthHandlers.ts index 151d4d418e..9f7bc7f6f9 100644 --- a/plugins/auth-node/src/oauth/createOAuthHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthHandlers.ts @@ -24,7 +24,7 @@ import { NotAllowedError, } from '@backstage/errors'; import { defaultStateDecoder, defaultStateEncoder, OAuthState } from './state'; -import { postMessageResponse } from '../flow'; +import { sendWebMessageResponse } from '../flow'; import { prepareBackstageIdentityResponse } from '../identity'; import { OAuthCookieManager } from './OAuthCookieManager'; import { @@ -238,7 +238,7 @@ export function createOAuthHandlers( res.redirect(state.redirectUrl); } // post message back to popup if successful - return postMessageResponse(res, appOrigin, { + return sendWebMessageResponse(res, appOrigin, { type: 'authorization_response', response, }); @@ -247,7 +247,7 @@ export function createOAuthHandlers( ? error : new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value // post error message back to popup if failure - return postMessageResponse(res, appOrigin, { + return sendWebMessageResponse(res, appOrigin, { type: 'authorization_response', error: { name, message }, });