Merge branch 'master' into redirect-oauth-flow

This commit is contained in:
headphonejames
2023-02-27 17:20:37 -08:00
1999 changed files with 73777 additions and 12080 deletions
@@ -0,0 +1,18 @@
/*
* Copyright 2021 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 { easyAuth } from './provider';
export type { EasyAuthResponse } from './provider';
@@ -0,0 +1,292 @@
/*
* 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 { AuthHandler, AuthResolverContext } from '../types';
import { makeProfileInfo } from '../../lib/passport';
import {
easyAuth,
ACCESS_TOKEN_HEADER,
EasyAuthAuthProvider,
EasyAuthResult,
ID_TOKEN_HEADER,
} from './provider';
import { Request, Response } from 'express';
import { SignJWT, JWTPayload, errors as JoseErrors } from 'jose';
import { randomBytes } from 'crypto';
const jwtSecret = randomBytes(48);
async function buildJwt(claims: JWTPayload) {
return await new SignJWT(claims)
.setProtectedHeader({ alg: 'HS256' })
.sign(jwtSecret);
}
const backstageIdentityTokenClaims = {
sub: 'user:default/alice',
ent: ['user:default/alice'],
};
describe('EasyAuthAuthProvider', () => {
const authHandler: AuthHandler<EasyAuthResult> = async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const resolverContext: AuthResolverContext = {} as AuthResolverContext;
async function signInResolver() {
return {
id: 'user.name',
token: await buildJwt(backstageIdentityTokenClaims),
};
}
const provider = new EasyAuthAuthProvider({
authHandler,
signInResolver,
resolverContext,
});
function mockRequest(headers?: Record<string, string>) {
return {
header: (name: string) => headers?.[name],
} as unknown as Request;
}
describe('should succeed when', () => {
it('id_token is valid and identity is resolved successfully', async () => {
const claims = {
ver: '2.0',
oid: 'c43063d4-0650-4f3e-ba6b-307473d24dfd',
name: 'Alice Bob',
email: 'alice@bob.com',
preferred_username: 'Another name',
};
const response = {
end: jest.fn(),
header: () => jest.fn(),
json: jest.fn(),
status: jest.fn(),
} as unknown as Response;
const request = mockRequest({
[ID_TOKEN_HEADER]: await buildJwt(claims),
});
await provider.refresh(request, response);
expect(response.json).toHaveBeenCalledWith({
backstageIdentity: {
id: 'user.name',
token: await buildJwt(backstageIdentityTokenClaims),
identity: {
ownershipEntityRefs: ['user:default/alice'],
type: 'user',
userEntityRef: 'user:default/alice',
},
},
profile: {
displayName: claims.name,
email: claims.email,
picture: undefined,
},
providerInfo: {
accessToken: undefined,
},
});
});
it('valid id_token and access_token provided', async () => {
const claims = {
ver: '2.0',
oid: 'c43063d4-0650-4f3e-ba6b-307473d24dfd',
name: 'Alice Bob',
email: 'alice@bob.com',
preferred_username: 'Another name',
};
const response = {
end: jest.fn(),
header: () => jest.fn(),
json: jest.fn(),
status: jest.fn(),
} as unknown as Response;
const request = mockRequest({
[ID_TOKEN_HEADER]: await buildJwt(claims),
[ACCESS_TOKEN_HEADER]: 'ACCESS_TOKEN',
});
await provider.refresh(request, response);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
providerInfo: {
accessToken: 'ACCESS_TOKEN',
},
}),
);
});
});
describe('should fail when', () => {
const response = {} as Response;
it('Access token is missing', async () => {
const request = mockRequest();
await expect(provider.refresh(request, response)).rejects.toThrow(
'Missing x-ms-token-aad-id-token header',
);
});
it('id token is invalid', async () => {
const request = mockRequest({
[ID_TOKEN_HEADER]: 'not-a-jwt',
});
await expect(provider.refresh(request, response)).rejects.toThrow(
JoseErrors.JWTInvalid,
);
});
it('id token is v1', async () => {
const request = mockRequest({
[ID_TOKEN_HEADER]: await buildJwt({ ver: '1.0' }),
});
await expect(provider.refresh(request, response)).rejects.toThrow(
'id_token is not version 2.0',
);
});
it('SignInResolver rejects', async () => {
const request = mockRequest({
[ID_TOKEN_HEADER]: await buildJwt({ ver: '2.0' }),
});
const rejectProvider = new EasyAuthAuthProvider({
authHandler,
signInResolver: async () => {
throw new Error('REJECTED!!');
},
resolverContext,
});
await expect(rejectProvider.refresh(request, response)).rejects.toThrow(
'REJECTED!!',
);
});
it('AuthHanlder rejects', async () => {
const request = mockRequest({
[ID_TOKEN_HEADER]: await buildJwt({ ver: '2.0' }),
});
const rejectProvider = new EasyAuthAuthProvider({
authHandler: async () => {
throw new Error('OBJECTION!!');
},
signInResolver,
resolverContext,
});
await expect(rejectProvider.refresh(request, response)).rejects.toThrow(
'OBJECTION!!',
);
});
});
});
describe('easyAuth factory', () => {
const env = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...env };
});
afterEach(() => {
process.env = env;
});
it('should fail when run outside of Azure App Services', async () => {
const factory = easyAuth.create({
signIn: {
resolver: jest.fn(),
},
});
expect(() => factory({} as any)).toThrow(
'Backstage is not running on Azure App Services',
);
});
it('should fail when Azure App Services Auth is not enabled', async () => {
process.env.WEBSITE_SKU = 'Standard';
process.env.WEBSITE_AUTH_ENABLED = 'False';
const factory = easyAuth.create({
signIn: {
resolver: jest.fn(),
},
});
expect(() => factory({} as any)).toThrow(
'Azure App Services does not have authentication enabled',
);
});
it('should fail when Azure App Services Auth is not AAD', async () => {
process.env.WEBSITE_SKU = 'Standard';
process.env.WEBSITE_AUTH_ENABLED = 'True';
process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'Facebook';
const factory = easyAuth.create({
signIn: {
resolver: jest.fn(),
},
});
expect(() => factory({} as any)).toThrow(
'Authentication provider is not Azure Active Directory',
);
});
it('should fail when Token Store not enabled', async () => {
process.env.WEBSITE_SKU = 'Standard';
process.env.WEBSITE_AUTH_ENABLED = 'True';
process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'AzureActiveDirectory';
process.env.WEBSITE_AUTH_TOKEN_STORE = 'False';
const factory = easyAuth.create({
signIn: {
resolver: jest.fn(),
},
});
expect(() => factory({} as any)).toThrow('Token Store is not enabled');
});
it('should return EasyAuthAuthProvider when running in Azure App Services with AAD Auth', async () => {
process.env.WEBSITE_SKU = 'Standard';
process.env.WEBSITE_AUTH_ENABLED = 'True';
process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'AzureActiveDirectory';
process.env.WEBSITE_AUTH_TOKEN_STORE = 'True';
const factory = easyAuth.create({
signIn: {
resolver: jest.fn(),
},
});
expect(factory({} as any)).toBeInstanceOf(EasyAuthAuthProvider);
});
});
@@ -0,0 +1,192 @@
/*
* 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 {
AuthHandler,
AuthProviderRouteHandlers,
AuthResolverContext,
AuthResponse,
SignInResolver,
} from '../types';
import { Request, Response } from 'express';
import { makeProfileInfo } from '../../lib/passport';
import { AuthenticationError } from '@backstage/errors';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { Profile } from 'passport';
import { decodeJwt } from 'jose';
export const ID_TOKEN_HEADER = 'x-ms-token-aad-id-token';
export const ACCESS_TOKEN_HEADER = 'x-ms-token-aad-access-token';
type Options = {
authHandler: AuthHandler<EasyAuthResult>;
signInResolver: SignInResolver<EasyAuthResult>;
resolverContext: AuthResolverContext;
};
export type EasyAuthResult = {
fullProfile: Profile;
accessToken?: string;
};
export type EasyAuthResponse = AuthResponse<{}>;
export class EasyAuthAuthProvider implements AuthProviderRouteHandlers {
private readonly resolverContext: AuthResolverContext;
private readonly authHandler: AuthHandler<EasyAuthResult>;
private readonly signInResolver: SignInResolver<EasyAuthResult>;
constructor(options: Options) {
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.resolverContext = options.resolverContext;
}
frameHandler(): Promise<void> {
return Promise.resolve(undefined);
}
async refresh(req: Request, res: Response): Promise<void> {
const result = await this.getResult(req);
const response = await this.handleResult(result);
res.json(response);
}
start(): Promise<void> {
return Promise.resolve(undefined);
}
private async getResult(req: Request): Promise<EasyAuthResult> {
const idToken = req.header(ID_TOKEN_HEADER);
const accessToken = req.header(ACCESS_TOKEN_HEADER);
if (idToken === undefined) {
throw new AuthenticationError(`Missing ${ID_TOKEN_HEADER} header`);
}
return {
fullProfile: this.idTokenToProfile(idToken),
accessToken: accessToken,
};
}
private idTokenToProfile(idToken: string) {
const claims = decodeJwt(idToken);
if (claims.ver !== '2.0') {
throw new Error('id_token is not version 2.0 ');
}
return {
id: claims.oid,
displayName: claims.name,
provider: 'easyauth',
emails: [{ value: claims.email }],
username: claims.preferred_username,
} as Profile;
}
private async handleResult(
result: EasyAuthResult,
): Promise<EasyAuthResponse> {
const { profile } = await this.authHandler(result, this.resolverContext);
const backstageIdentity = await this.signInResolver(
{
result,
profile,
},
this.resolverContext,
);
return {
providerInfo: {
accessToken: result.accessToken,
},
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
profile,
};
}
}
/**
* Auth provider integration for Azure EasyAuth
*
* @public
*/
export const easyAuth = createAuthProviderIntegration({
create(options?: {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<EasyAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<EasyAuthResult>;
};
}) {
return ({ resolverContext }) => {
validateAppServiceConfiguration(process.env);
if (options?.signIn.resolver === undefined) {
throw new Error(
'SignInResolver is required to use this authentication provider',
);
}
const authHandler =
options.authHandler ??
(async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}));
return new EasyAuthAuthProvider({
signInResolver: options.signIn.resolver,
authHandler,
resolverContext,
});
};
},
});
function validateAppServiceConfiguration(env: NodeJS.ProcessEnv) {
// Based on https://github.com/AzureAD/microsoft-identity-web/blob/f7403779d1a91f4a3fec0ed0993bd82f50f299e1/src/Microsoft.Identity.Web/AppServicesAuth/AppServicesAuthenticationInformation.cs#L38-L59
//
// It's critical to validate we're really running in a correctly configured Azure App Services,
// As we rely on App Services to manage & validate the ID and Access Token headers
// Without that, this users can be trivially impersonated.
if (env.WEBSITE_SKU === undefined) {
throw new Error('Backstage is not running on Azure App Services');
}
if (env.WEBSITE_AUTH_ENABLED?.toLowerCase() !== 'true') {
throw new Error('Azure App Services does not have authentication enabled');
}
if (
env.WEBSITE_AUTH_DEFAULT_PROVIDER?.toLowerCase() !== 'azureactivedirectory'
) {
throw new Error('Authentication provider is not Azure Active Directory');
}
if (process.env.WEBSITE_AUTH_TOKEN_STORE?.toLowerCase() !== 'true') {
throw new Error('Token Store is not enabled');
}
}
@@ -0,0 +1,18 @@
/*
* 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 { bitbucketServer } from './provider';
export type { BitbucketServerOAuthResult } from './provider';
@@ -0,0 +1,390 @@
/*
* 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 * as helpers from '../../lib/passport/PassportStrategyHelper';
import { makeProfileInfo } from '../../lib/passport';
import { AuthResolverContext } from '../types';
import {
bitbucketServer,
BitbucketServerAuthProvider,
BitbucketServerOAuthResult,
} from './provider';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
jest.mock('../../lib/passport/PassportStrategyHelper', () => {
return {
...jest.requireActual('../../lib/passport/PassportStrategyHelper'),
executeFrameHandlerStrategy: jest.fn(),
executeRefreshTokenStrategy: jest.fn(),
executeFetchUserProfileStrategy: jest.fn(),
};
});
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<
() => Promise<{
result: BitbucketServerOAuthResult;
privateInfo: { refreshToken?: string };
}>
>;
const passportProfile = {
id: '123',
username: 'john.doe',
provider: 'bitubcketServer',
displayName: 'John Doe',
emails: [{ value: 'john@doe.com' }],
photos: [{ value: 'https://bitbucket.org/user/123/avatar' }],
};
const mockHost = 'bitbucket.org';
const mockBaseUrl = `https://${mockHost}`;
const whoAmIHandler = (options?: { fail?: boolean; value?: string }) =>
rest.get(
`${mockBaseUrl}/plugins/servlet/applinks/whoami`,
(_req, res, ctx) => {
if (options?.fail) {
res.networkError('error');
}
return res(
ctx.status(200),
ctx.set('X-Ausername', options?.value ?? passportProfile.username),
);
},
);
const getUserHandler = (options?: {
fail?: boolean;
status?: number;
avatarUrl?: string;
noDisplayName?: boolean;
noUserName?: boolean;
}) =>
rest.get(
`${mockBaseUrl}/rest/api/latest/users/${passportProfile.username}`,
(_req, res, ctx) => {
if (options?.fail) {
res.networkError('error');
}
return res(
ctx.status(options?.status ?? 200),
ctx.json({
name: options?.noUserName ? undefined : 'john.doe',
emailAddress: 'john@doe.com',
id: 123,
displayName: options?.noDisplayName ? undefined : 'John Doe',
active: true,
slug: 'john.doe',
type: 'NORMAL',
links: {
self: [
{
href: 'https://bitbucket.org/users/john.doe',
},
],
},
avatarUrl: options?.avatarUrl ?? '/user/123/avatar',
}),
);
},
);
describe('BitbucketServerAuthProvider', () => {
const provider = new BitbucketServerAuthProvider({
resolverContext: {
signInWithCatalogUser: jest.fn(info => {
return {
token: `token-for-user:${info.filter['spec.profile.email']}`,
};
}),
} as unknown as AuthResolverContext,
signInResolver:
bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(),
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
callbackUrl: 'mock',
clientId: 'mock',
clientSecret: 'mock',
host: mockHost,
authorizationUrl: 'mock',
tokenUrl: 'mock',
});
describe('when transforming to type OAuthResponse', () => {
const server = setupServer();
setupRequestMockHandlers(server);
it('should map to a valid response', async () => {
server.use(whoAmIHandler(), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should throw if whoami fails', async () => {
server.use(whoAmIHandler({ fail: true }), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the username of the logged in user`,
);
});
it('should throw if whoami returns an invalid response', async () => {
server.use(whoAmIHandler({ value: '' }), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the username of the logged in user`,
);
});
it('should throw if get user fails', async () => {
server.use(whoAmIHandler(), getUserHandler({ fail: true }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the user '${passportProfile.username}'`,
);
});
it('should throw if get user is not ok', async () => {
server.use(whoAmIHandler(), getUserHandler({ status: 500 }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the user '${passportProfile.username}'`,
);
});
it('should not set an avatar url if not given', async () => {
server.use(whoAmIHandler(), getUserHandler({ avatarUrl: '' }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should fallback to the username if no displayName is given', async () => {
server.use(whoAmIHandler(), getUserHandler({ noDisplayName: true }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'john.doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should fallback to the user id if no name is given', async () => {
server.use(
whoAmIHandler(),
getUserHandler({ noDisplayName: true, noUserName: true }),
);
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: '123',
picture: 'https://bitbucket.org/user/123/avatar',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
});
describe('when authenticating', () => {
const server = setupServer();
setupRequestMockHandlers(server);
it('should forward the refresh token', async () => {
server.use(whoAmIHandler(), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: { refreshToken: 'refresh-token' },
});
const response = await provider.handler({} as any);
const expected = {
response: {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
},
refreshToken: 'refresh-token',
};
expect(response).toEqual(expected);
});
it('should forward a new refresh token on refresh', async () => {
server.use(whoAmIHandler(), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const mockRefreshToken = jest.spyOn(
helpers,
'executeRefreshTokenStrategy',
) as unknown as jest.MockedFunction<() => Promise<{}>>;
mockRefreshToken.mockResolvedValueOnce({
accessToken,
refreshToken: 'dont-forget-to-send-refresh',
params,
});
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: { refreshToken: 'refresh-token' },
});
const expected = {
response: {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
},
refreshToken: 'dont-forget-to-send-refresh',
};
const response = await provider.refresh({ scope: 'REPO_WRITE' } as any);
expect(response).toEqual(expected);
});
});
});
@@ -0,0 +1,305 @@
/*
* Copyright 2023 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 {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
} from '../../lib/oauth';
import { Strategy as OAuth2Strategy, VerifyCallback } from 'passport-oauth2';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/passport';
import {
AuthHandler,
AuthResolverContext,
OAuthStartResponse,
SignInResolver,
} from '../types';
import express from 'express';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { Profile as PassportProfile } from 'passport';
import { commonByEmailResolver } from '../resolvers';
import fetch from 'node-fetch';
type PrivateInfo = {
refreshToken: string;
};
/** @public */
export type BitbucketServerOAuthResult = {
fullProfile: PassportProfile;
params: {
scope: string;
access_token?: string;
token_type?: string;
expires_in?: number;
};
accessToken: string;
refreshToken?: string;
};
export type BitbucketServerAuthProviderOptions = OAuthProviderOptions & {
host: string;
authorizationUrl: string;
tokenUrl: string;
authHandler: AuthHandler<BitbucketServerOAuthResult>;
signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
resolverContext: AuthResolverContext;
};
export class BitbucketServerAuthProvider implements OAuthHandlers {
private readonly signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
private readonly authHandler: AuthHandler<BitbucketServerOAuthResult>;
private readonly resolverContext: AuthResolverContext;
private readonly strategy: OAuth2Strategy;
private readonly host: string;
constructor(options: BitbucketServerAuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.resolverContext = options.resolverContext;
this.strategy = new OAuth2Strategy(
{
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
},
(
accessToken: string,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: VerifyCallback,
) => {
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
},
);
this.host = options.host;
}
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
return await executeRedirectStrategy(req, this.strategy, {
accessType: 'offline',
prompt: 'consent',
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { result, privateInfo } = await executeFrameHandlerStrategy<
BitbucketServerOAuthResult,
PrivateInfo
>(req, this.strategy);
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(
req: OAuthRefreshRequest,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this.strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this.strategy,
accessToken,
);
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(
result: BitbucketServerOAuthResult,
): Promise<OAuthResponse> {
// The OAuth2 strategy does not return a user profile -> let's fetch it before calling the auth handler
result.fullProfile = await this.fetchProfile(result);
const { profile } = await this.authHandler(result, this.resolverContext);
let backstageIdentity = undefined;
if (this.signInResolver) {
backstageIdentity = await this.signInResolver(
{ result, profile },
this.resolverContext,
);
}
return {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
backstageIdentity,
};
}
private async fetchProfile(
result: BitbucketServerOAuthResult,
): Promise<PassportProfile> {
// Get current user name
let whoAmIResponse;
try {
whoAmIResponse = await fetch(
`https://${this.host}/plugins/servlet/applinks/whoami`,
{
headers: {
Authorization: `Bearer ${result.accessToken}`,
},
},
);
} catch (e) {
throw new Error(`Failed to retrieve the username of the logged in user`);
}
// A response.ok check here would be worthless as the Bitbucket API always returns 200 OK for this call
const username = whoAmIResponse.headers.get('X-Ausername');
if (!username) {
throw new Error(`Failed to retrieve the username of the logged in user`);
}
let userResponse;
try {
userResponse = await fetch(
`https://${this.host}/rest/api/latest/users/${username}?avatarSize=256`,
{
headers: {
Authorization: `Bearer ${result.accessToken}`,
},
},
);
} catch (e) {
throw new Error(`Failed to retrieve the user '${username}'`);
}
if (!userResponse.ok) {
throw new Error(`Failed to retrieve the user '${username}'`);
}
const user = (await userResponse.json()) as any;
const passportProfile = {
provider: 'bitbucketServer',
id: user.id.toString(),
displayName: user.displayName,
username: user.name,
emails: [
{
value: user.emailAddress,
},
],
} as PassportProfile;
if (user.avatarUrl) {
passportProfile.photos = [
{ value: `https://${this.host}${user.avatarUrl}` },
];
}
return passportProfile;
}
}
export const bitbucketServer = createAuthProviderIntegration({
create(options?: {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<BitbucketServerOAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<BitbucketServerOAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const host = envConfig.getString('host');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://${host}/rest/oauth2/latest/authorize`;
const tokenUrl = `https://${host}/rest/oauth2/latest/token`;
const authHandler: AuthHandler<BitbucketServerOAuthResult> =
options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const provider = new BitbucketServerAuthProvider({
callbackUrl,
clientId,
clientSecret,
host,
authorizationUrl,
tokenUrl,
authHandler,
signInResolver: options?.signIn?.resolver,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
resolvers: {
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail:
(): SignInResolver<BitbucketServerOAuthResult> => commonByEmailResolver,
},
});
@@ -83,6 +83,7 @@ const mockCacheClient = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
withOptions: jest.fn(),
};
jest.mock('jose');
@@ -19,6 +19,7 @@ export type {
BitbucketOAuthResult,
BitbucketPassportProfile,
} from './bitbucket';
export type { BitbucketServerOAuthResult } from './bitbucketServer';
export type {
CloudflareAccessClaims,
CloudflareAccessGroup,
@@ -31,6 +31,7 @@ import { okta } from './okta';
import { onelogin } from './onelogin';
import { saml } from './saml';
import { AuthProviderFactory } from './types';
import { bitbucketServer } from './bitbucketServer';
/**
* All built-in auth provider integrations.
@@ -42,6 +43,7 @@ export const providers = Object.freeze({
auth0,
awsAlb,
bitbucket,
bitbucketServer,
cfAccess,
gcpIap,
github,
@@ -76,5 +78,6 @@ export const defaultAuthProviderFactories: {
onelogin: onelogin.create(),
awsalb: awsAlb.create(),
bitbucket: bitbucket.create(),
bitbucketServer: bitbucketServer.create(),
atlassian: atlassian.create(),
};