removed flow, passport and more
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -1,203 +0,0 @@
|
||||
/*
|
||||
* 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,
|
||||
ensuresXRequestedWith,
|
||||
postMessageResponse,
|
||||
} from './authFlowHelpers';
|
||||
import { WebMessageResponse } from './types';
|
||||
|
||||
describe('oauth helpers', () => {
|
||||
describe('safelyEncodeURIComponent', () => {
|
||||
it('encodes all occurrences of single quotes', () => {
|
||||
expect(safelyEncodeURIComponent("a'ö'b")).toBe('a%27%C3%B6%27b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postMessageResponse', () => {
|
||||
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));
|
||||
|
||||
postMessageResponse(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));
|
||||
|
||||
postMessageResponse(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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
postMessageResponse(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'),
|
||||
};
|
||||
postMessageResponse(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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
postMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledTimes(3);
|
||||
expect(mockResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Adam%20l%27H%C3%B4pital'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensuresXRequestedWith', () => {
|
||||
it('should return false if no header present', () => {
|
||||
const mockRequest = {
|
||||
header: () => jest.fn(),
|
||||
} as unknown as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if header present with incorrect value', () => {
|
||||
const mockRequest = {
|
||||
header: () => 'INVALID',
|
||||
} as unknown as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if header present with correct value', () => {
|
||||
const mockRequest = {
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* 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 crypto from 'crypto';
|
||||
import { WebMessageResponse } from './types';
|
||||
|
||||
export const safelyEncodeURIComponent = (value: 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
|
||||
* @deprecated Use `sendWebMessageResponse` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export const postMessageResponse = (
|
||||
res: express.Response,
|
||||
appOrigin: string,
|
||||
response: WebMessageResponse,
|
||||
) => {
|
||||
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>`);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use inline logic to check that the `X-Requested-With` header is set to `'XMLHttpRequest'` instead.
|
||||
*/
|
||||
export const ensuresXRequestedWith = (req: express.Request) => {
|
||||
const requiredHeader = req.header('X-Requested-With');
|
||||
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* 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 { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
|
||||
|
||||
export type { WebMessageResponse } from './types';
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* 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 { WebMessageResponse as _WebMessageResponse } from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type WebMessageResponse = _WebMessageResponse;
|
||||
@@ -1,360 +0,0 @@
|
||||
/*
|
||||
* 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 { UnsecuredJWT } from 'jose';
|
||||
import passport from 'passport';
|
||||
import { InternalOAuthError } from 'passport-oauth2';
|
||||
import {
|
||||
executeRedirectStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
} from './PassportStrategyHelper';
|
||||
import { PassportProfile } from './types';
|
||||
|
||||
const mockRequest = {} as unknown as express.Request;
|
||||
|
||||
describe('PassportStrategyHelper', () => {
|
||||
describe('makeProfileInfo', () => {
|
||||
it('retrieves email from passport profile', () => {
|
||||
const profile: PassportProfile = {
|
||||
emails: [{ value: 'email' }],
|
||||
provider: '',
|
||||
id: '',
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
const profileInfo = makeProfileInfo(profile);
|
||||
|
||||
expect(profileInfo.email).toEqual('email');
|
||||
});
|
||||
|
||||
it('retrieves picture from passport profile avatarUrl', () => {
|
||||
const profile: PassportProfile = {
|
||||
avatarUrl: 'avatarUrl',
|
||||
provider: '',
|
||||
id: '',
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
const profileInfo = makeProfileInfo(profile);
|
||||
|
||||
expect(profileInfo.picture).toEqual('avatarUrl');
|
||||
});
|
||||
|
||||
it('falls back to picture from passport profile photos field', () => {
|
||||
const profile: PassportProfile = {
|
||||
photos: [{ value: 'picture' }],
|
||||
provider: '',
|
||||
id: '',
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
const profileInfo = makeProfileInfo(profile);
|
||||
|
||||
expect(profileInfo.picture).toEqual('picture');
|
||||
});
|
||||
|
||||
it('falls back to email from ID token', async () => {
|
||||
const profile: PassportProfile = {
|
||||
provider: '',
|
||||
id: '',
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
const profileInfo = makeProfileInfo(
|
||||
profile,
|
||||
await new UnsecuredJWT({ email: 'email' }).encode(),
|
||||
);
|
||||
|
||||
expect(profileInfo.email).toEqual('email');
|
||||
});
|
||||
|
||||
it('falls back to picture from ID token', async () => {
|
||||
const profile: PassportProfile = {
|
||||
provider: '',
|
||||
id: '',
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
const profileInfo = makeProfileInfo(
|
||||
profile,
|
||||
await new UnsecuredJWT({ picture: 'picture' }).encode(),
|
||||
);
|
||||
|
||||
expect(profileInfo.picture).toEqual('picture');
|
||||
});
|
||||
|
||||
it('falls back to name from ID token', async () => {
|
||||
const profile: PassportProfile = {
|
||||
provider: '',
|
||||
id: '',
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
const profileInfo = makeProfileInfo(
|
||||
profile,
|
||||
await new UnsecuredJWT({ name: 'name' }).encode(),
|
||||
);
|
||||
|
||||
expect(profileInfo.displayName).toEqual('name');
|
||||
});
|
||||
|
||||
it('fails when attempting to fall back to invalid JWT', () => {
|
||||
const profile: PassportProfile = {
|
||||
provider: '',
|
||||
id: '',
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
expect(() => makeProfileInfo(profile, 'invalid JWT')).toThrow(
|
||||
'Failed to parse id token and get profile info',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
class MyCustomRedirectStrategy extends passport.Strategy {
|
||||
authenticate() {
|
||||
this.redirect('a', 302);
|
||||
}
|
||||
}
|
||||
|
||||
describe('executeRedirectStrategy', () => {
|
||||
it('should call authenticate and resolve with OAuthStartResponse', async () => {
|
||||
const mockStrategy = new MyCustomRedirectStrategy();
|
||||
const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate');
|
||||
const redirectStrategyPromise = executeRedirectStrategy(
|
||||
mockRequest,
|
||||
mockStrategy,
|
||||
{},
|
||||
);
|
||||
expect(spyAuthenticate).toHaveBeenCalledTimes(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 InternalOAuthError('MyCustomAuth error', {
|
||||
data: '{ "message": "Custom message" }',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
await expect(frameHandlerStrategyPromise).resolves.toStrictEqual(
|
||||
expect.objectContaining({
|
||||
result: { accessToken: 'ACCESS_TOKEN' },
|
||||
privateInfo: { 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).toHaveBeenCalledTimes(1);
|
||||
await expect(frameHandlerStrategyPromise).rejects.toThrow(
|
||||
'Authentication failed, MyCustomAuth error - Custom message',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject on redirect', async () => {
|
||||
const mockStrategy = new MyCustomAuthRedirectStrategy();
|
||||
const spyAuthenticate = jest.spyOn(mockStrategy, 'authenticate');
|
||||
const frameHandlerStrategyPromise = executeFrameHandlerStrategy(
|
||||
mockRequest,
|
||||
mockStrategy,
|
||||
);
|
||||
expect(spyAuthenticate).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(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 {
|
||||
_oauth2 = new MyCustomOAuth2Success();
|
||||
userProfile(_accessToken: string, callback: Function) {
|
||||
callback(null, {
|
||||
provider: 'a',
|
||||
email: 'b',
|
||||
name: 'c',
|
||||
picture: 'd',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 forward simple errors', async () => {
|
||||
class MyCustomRefreshTokenSuccess extends passport.Strategy {
|
||||
_oauth2 = new (class {
|
||||
getOAuthAccessToken(_r: string, _o: any, cb: Function) {
|
||||
cb(new Error('Unknown error'));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await expect(
|
||||
executeRefreshTokenStrategy(
|
||||
new MyCustomRefreshTokenSuccess(),
|
||||
'REFRESH_TOKEN',
|
||||
'a',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Failed to refresh access token; caused by Error: Unknown error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward string errors', async () => {
|
||||
class MyCustomRefreshTokenSuccess extends passport.Strategy {
|
||||
_oauth2 = new (class {
|
||||
getOAuthAccessToken(_r: string, _o: any, cb: Function) {
|
||||
cb('some silly string error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await expect(
|
||||
executeRefreshTokenStrategy(
|
||||
new MyCustomRefreshTokenSuccess(),
|
||||
'REFRESH_TOKEN',
|
||||
'a',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Failed to refresh access token; caused by unknown error 'some silly string error'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward object errors', async () => {
|
||||
class MyCustomRefreshTokenSuccess extends passport.Strategy {
|
||||
_oauth2 = new (class {
|
||||
getOAuthAccessToken(_r: string, _o: any, cb: Function) {
|
||||
cb({ name: 'SomeError', message: 'some message' });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await expect(
|
||||
executeRefreshTokenStrategy(
|
||||
new MyCustomRefreshTokenSuccess(),
|
||||
'REFRESH_TOKEN',
|
||||
'a',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Failed to refresh access token; caused by SomeError: some message',
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
_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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
/*
|
||||
* 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 passport from 'passport';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { InternalOAuthError } from 'passport-oauth2';
|
||||
import { ProfileInfo } from '@backstage/plugin-auth-node';
|
||||
import { PassportProfile } from './types';
|
||||
import { OAuthStartResponse } from '../../providers/types';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
export type PassportDoneCallback<Res, Private = never> = (
|
||||
err?: Error,
|
||||
response?: Res,
|
||||
privateInfo?: Private,
|
||||
) => void;
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: PassportProfile,
|
||||
idToken?: string,
|
||||
): ProfileInfo => {
|
||||
let email: string | undefined = undefined;
|
||||
if (profile.emails && profile.emails.length > 0) {
|
||||
const [firstEmail] = profile.emails;
|
||||
email = firstEmail.value;
|
||||
}
|
||||
|
||||
let picture: string | undefined = undefined;
|
||||
if (profile.avatarUrl) {
|
||||
picture = profile.avatarUrl;
|
||||
} else if (profile.photos && profile.photos.length > 0) {
|
||||
const [firstPhoto] = profile.photos;
|
||||
picture = firstPhoto.value;
|
||||
}
|
||||
|
||||
let displayName: string | undefined =
|
||||
profile.displayName ?? profile.username ?? profile.id;
|
||||
|
||||
if ((!email || !picture || !displayName) && idToken) {
|
||||
try {
|
||||
const decoded = decodeJwt(idToken) as {
|
||||
email?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
};
|
||||
if (!email && decoded.email) {
|
||||
email = decoded.email;
|
||||
}
|
||||
if (!picture && decoded.picture) {
|
||||
picture = decoded.picture;
|
||||
}
|
||||
if (!displayName && decoded.name) {
|
||||
displayName = decoded.name;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new ForwardedError(
|
||||
`Failed to parse id token and get profile info`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
picture,
|
||||
displayName,
|
||||
};
|
||||
};
|
||||
|
||||
export const executeRedirectStrategy = async (
|
||||
req: express.Request,
|
||||
providerStrategy: passport.Strategy,
|
||||
options: Record<string, string>,
|
||||
): Promise<OAuthStartResponse> => {
|
||||
return new Promise(resolve => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.redirect = (url: string, status?: number) => {
|
||||
resolve({ url, status: status ?? undefined });
|
||||
};
|
||||
|
||||
strategy.authenticate(req, { ...options });
|
||||
});
|
||||
};
|
||||
|
||||
export const executeFrameHandlerStrategy = async <Result, PrivateInfo = never>(
|
||||
req: express.Request,
|
||||
providerStrategy: passport.Strategy,
|
||||
options?: Record<string, string>,
|
||||
) => {
|
||||
return new Promise<{ result: Result; privateInfo: PrivateInfo }>(
|
||||
(resolve, reject) => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.success = (result: any, privateInfo: any) => {
|
||||
resolve({ result, privateInfo });
|
||||
};
|
||||
strategy.fail = (
|
||||
info: { type: 'success' | 'error'; message?: string },
|
||||
// _status: number,
|
||||
) => {
|
||||
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
|
||||
};
|
||||
strategy.error = (error: InternalOAuthError) => {
|
||||
let message = `Authentication failed, ${error.message}`;
|
||||
|
||||
if (error.oauthError?.data) {
|
||||
try {
|
||||
const errorData = JSON.parse(error.oauthError.data);
|
||||
|
||||
if (errorData.message) {
|
||||
message += ` - ${errorData.message}`;
|
||||
}
|
||||
} catch (parseError) {
|
||||
message += ` - ${error.oauthError}`;
|
||||
}
|
||||
}
|
||||
|
||||
reject(new Error(message));
|
||||
};
|
||||
strategy.redirect = () => {
|
||||
reject(new Error('Unexpected redirect'));
|
||||
};
|
||||
strategy.authenticate(req, { ...(options ?? {}) });
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
/**
|
||||
* An access token issued for the signed in user.
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* Optionally, the server can issue a new Refresh Token for the user
|
||||
*/
|
||||
refreshToken?: string;
|
||||
params: any;
|
||||
};
|
||||
|
||||
export const executeRefreshTokenStrategy = async (
|
||||
providerStrategy: passport.Strategy,
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<RefreshTokenResponse> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerStrategy as any;
|
||||
const OAuth2 = anyStrategy._oauth2.constructor;
|
||||
const oauth2 = new OAuth2(
|
||||
anyStrategy._oauth2._clientId,
|
||||
anyStrategy._oauth2._clientSecret,
|
||||
anyStrategy._oauth2._baseSite,
|
||||
anyStrategy._oauth2._authorizeUrl,
|
||||
anyStrategy._refreshURL || anyStrategy._oauth2._accessTokenUrl,
|
||||
anyStrategy._oauth2._customHeaders,
|
||||
);
|
||||
|
||||
oauth2.getOAuthAccessToken(
|
||||
refreshToken,
|
||||
{
|
||||
scope,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
(
|
||||
err: Error | null,
|
||||
accessToken: string,
|
||||
newRefreshToken: string,
|
||||
params: any,
|
||||
) => {
|
||||
if (err) {
|
||||
reject(new ForwardedError(`Failed to refresh access token`, err));
|
||||
}
|
||||
if (!accessToken) {
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to refresh access token, no access token received`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
params,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
type ProviderStrategy = {
|
||||
userProfile(accessToken: string, callback: Function): void;
|
||||
};
|
||||
|
||||
export const executeFetchUserProfileStrategy = async (
|
||||
providerStrategy: passport.Strategy,
|
||||
accessToken: string,
|
||||
): Promise<PassportProfile> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerStrategy as unknown as ProviderStrategy;
|
||||
anyStrategy.userProfile(
|
||||
accessToken,
|
||||
(error: Error, rawProfile: PassportProfile) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(rawProfile);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
executeFetchUserProfileStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
} from './PassportStrategyHelper';
|
||||
export type { PassportDoneCallback } from './PassportStrategyHelper';
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* 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 passport from 'passport';
|
||||
|
||||
export type PassportProfile = passport.Profile & {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
@@ -39,16 +39,7 @@ import {
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { CatalogIdentityClient } from '../catalog';
|
||||
|
||||
/**
|
||||
* Uses the default ownership resolution logic to return an array
|
||||
* of entity refs that the provided entity claims ownership through.
|
||||
*
|
||||
* A reference to the entity itself will also be included in the returned array.
|
||||
*
|
||||
* @public
|
||||
* @deprecated use `ctx.resolveOwnershipEntityRefs(entity)` from the provided `AuthResolverContext` instead.
|
||||
*/
|
||||
export function getDefaultOwnershipEntityRefs(entity: Entity) {
|
||||
function getDefaultOwnershipEntityRefs(entity: Entity) {
|
||||
const membershipRefs =
|
||||
entity.relations
|
||||
?.filter(
|
||||
@@ -59,9 +50,6 @@ export function getDefaultOwnershipEntityRefs(entity: Entity) {
|
||||
return Array.from(new Set([stringifyEntityRef(entity), ...membershipRefs]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export class CatalogAuthResolverContext implements AuthResolverContext {
|
||||
static create(options: {
|
||||
logger: LoggerService;
|
||||
|
||||
@@ -14,7 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
CatalogAuthResolverContext,
|
||||
getDefaultOwnershipEntityRefs,
|
||||
} from './CatalogAuthResolverContext';
|
||||
export { CatalogAuthResolverContext } from './CatalogAuthResolverContext';
|
||||
|
||||
@@ -15,9 +15,3 @@
|
||||
*/
|
||||
|
||||
export { createOriginFilter, type ProviderFactories } from './router';
|
||||
|
||||
export type {
|
||||
AuthHandler,
|
||||
AuthHandlerResult,
|
||||
OAuthStartResponse,
|
||||
} from './types';
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
AuthResolverContext as _AuthResolverContext,
|
||||
ProfileInfo as _ProfileInfo,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthAuthenticator` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthStartResponse = {
|
||||
/**
|
||||
* URL to redirect to
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Status code to use for the redirect
|
||||
*/
|
||||
status?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The return type of an authentication handler. Must contain valid profile
|
||||
* information.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type AuthHandlerResult = { profile: _ProfileInfo };
|
||||
|
||||
/**
|
||||
* The AuthHandler function is called every time the user authenticates using
|
||||
* the provider.
|
||||
*
|
||||
* The handler should return a profile that represents the session for the user
|
||||
* in the frontend.
|
||||
*
|
||||
* Throwing an error in the function will cause the authentication to fail,
|
||||
* making it possible to use this function as a way to limit access to a certain
|
||||
* group of users.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type AuthHandler<TAuthResult> = (
|
||||
input: TAuthResult,
|
||||
context: _AuthResolverContext,
|
||||
) => Promise<AuthHandlerResult>;
|
||||
Reference in New Issue
Block a user