feat(auth): add auth provider for Bitbucket Server

Signed-off-by: Katharina Sick <katharina.sick@dynatrace.com>
This commit is contained in:
Katharina Sick
2023-02-06 08:54:01 +01:00
parent 50d7039368
commit db10b6ef65
19 changed files with 983 additions and 1 deletions
@@ -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,377 @@
/*
* 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/PassportStrategyHelper';
import { AuthResolverContext } from '../types';
import {
BitbucketServerAuthProvider,
BitbucketServerOAuthResult,
} from './provider';
import { commonByEmailResolver } from '../resolvers';
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 mockFetchUserRequests = (
failOnWhoAmI: boolean = false,
whoAmIValue: string = passportProfile.username,
failOnGetUser: boolean = false,
getUserOk: boolean = true,
avatarUrl: string = '/user/123/avatar',
setDisplayName: boolean = true,
setUserName: boolean = true,
) => {
const fetchMock = global.fetch as jest.Mock;
if (failOnWhoAmI) {
fetchMock.mockRejectedValueOnce(() => {});
} else {
fetchMock.mockResolvedValueOnce({
headers: { get: jest.fn(() => whoAmIValue) },
});
}
if (failOnGetUser) {
fetchMock.mockRejectedValueOnce(() => {});
} else {
fetchMock.mockResolvedValueOnce({
ok: getUserOk,
json: () => ({
name: setUserName ? 'john.doe' : undefined,
emailAddress: 'john@doe.com',
id: 123,
displayName: setDisplayName ? 'John Doe' : undefined,
active: true,
slug: 'john.doe',
type: 'NORMAL',
links: {
self: [
{
href: 'https://bitbucket.org/users/john.doe',
},
],
},
avatarUrl: avatarUrl,
}),
});
}
};
describe('BitbucketServerAuthProvider', () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
const provider = new BitbucketServerAuthProvider({
resolverContext: {
signInWithCatalogUser: jest.fn(info => {
return {
token: `token-for-user:${info.filter['spec.profile.email']}`,
};
}),
} as unknown as AuthResolverContext,
signInResolver: commonByEmailResolver,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
callbackUrl: 'mock',
clientId: 'mock',
clientSecret: 'mock',
host: 'bitbucket.org',
authorizationUrl: 'mock',
tokenUrl: 'mock',
});
describe('when transforming to type OAuthResponse', () => {
it('should map to a valid response', async () => {
mockFetchUserRequests();
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 () => {
mockFetchUserRequests(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 username of the logged in user`,
);
});
it('should throw if whoami returns an invalid response', async () => {
mockFetchUserRequests(false, '');
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 () => {
mockFetchUserRequests(false, passportProfile.username, 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 () => {
mockFetchUserRequests(false, passportProfile.username, false, false);
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 () => {
mockFetchUserRequests(false, passportProfile.username, false, 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',
},
};
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 () => {
mockFetchUserRequests(
false,
passportProfile.username,
false,
true,
'/user/123/avatar',
false,
);
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 () => {
mockFetchUserRequests(
false,
passportProfile.username,
false,
true,
'/user/123/avatar',
false,
false,
);
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', () => {
it('should forward the refresh token', async () => {
mockFetchUserRequests();
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 () => {
mockFetchUserRequests();
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,297 @@
/*
* 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 { PassportProfile } from '../../lib/passport/types';
import { commonByEmailResolver } from '../resolvers';
type PrivateInfo = {
refreshToken: string;
};
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();
return {
provider: 'bitbucketServer',
id: user.id.toString(),
displayName: user.displayName,
username: user.name,
emails: [
{
value: user.emailAddress,
},
],
avatarUrl: user.avatarUrl
? `https://${this.host}${user.avatarUrl}`
: undefined,
};
}
}
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: () => commonByEmailResolver,
},
});
@@ -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(),
};