auth-node: add sendWebMessageResponse
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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>`);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user