auth-node: add sendWebMessageResponse

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-07-26 16:19:59 +02:00
parent ac8f47aa69
commit 52af2a8472
5 changed files with 290 additions and 3 deletions
+20
View File
@@ -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';
@@ -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'),
);
});
});
});
@@ -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<unknown>;
}
| {
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(`<html><body><script>${script}</script></body></html>`);
}
+1
View File
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export * from './flow';
export * from './identity';
export * from './passport';
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
@@ -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<TProfile>(
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<TProfile>(
? 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 },
});