feat: migrate bitbucket server provider to the new backend system

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-09-02 09:07:18 +02:00
parent 98b92a621e
commit 527d973410
23 changed files with 752 additions and 647 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Migrated the `Bitbucket Server` auth provider to be implemented using the new `@backstage/plugin-auth-backend-module-bitbucket-server-provider` module.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-bitbucket-server-provider': minor
---
New module for `@backstage/plugin-auth-backend` that adds a `Bitbucket Server` auth provider.
@@ -0,0 +1,60 @@
---
id: provider--od
title: Bitbucket Server Authentication Provider
sidebar_label: Bitbucket Server
description: Adding Bitbucket Server OAuth as an authentication provider in Backstage
---
:::info
This documentation is written for the old backend which has been replaced by
[the new backend system](../../backend-system/index.md), being the default since
Backstage [version 1.24](../../releases/v1.24.0.md). If have migrated to the new
backend system, you may want to read [its own article](./provider.md)
instead. Otherwise, [consider migrating](../../backend-system/building-backends/08-migrating.md)!
:::
The Backstage `core-plugin-api` package comes with a Bitbucket Server authentication provider that can authenticate
users using Bitbucket Server. This does **NOT** work with Bitbucket Cloud.
## Create an Application Link in Bitbucket Server
To add Bitbucket Server authentication, you must create an incoming application link. Follow the steps described in
the [Bitbucket Server documentation](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html)
to create one.
## Configuration
The provider configuration can then be added to your `app-config.yaml` under the root `auth` configuration:
```yaml
auth:
environment: development
providers:
bitbucketServer:
development:
host: bitbucket.org
clientId: ${AUTH_BITBUCKET_SERVER_CLIENT_ID}
clientSecret: ${AUTH_BITBUCKET_SERVER_CLIENT_SECRET}
```
The Bitbucket Server provider is a structure with two configuration keys:
- `clientId`: The client ID that was generated by Bitbucket, e.g. `b0f868455c15dcdff5c5fb5d173ae684`.
- `clientSecret`: The client secret tied to the generated client ID.
## Adding the provider to the Backstage frontend
To add the provider to the frontend, add the `bitbucketServerAuthApi` reference and `SignInPage` component as shown
in [Adding the provider to the sign-in page](../index.md#sign-in-configuration).
## Using Bitbucket Server for sign-in
In order to use the Bitbucket Server provider for sign-in, you must configure it with a `signIn.resolver`. See
the [Sign-In Resolver documentation](../identity-resolver.md) for more details on how this is done. Note that for the
Bitbucket Server provider, you'll want to use `bitbucketServer` as the provider ID,
and `providers.bitbucketServer.create` for the provider factory.
The `@backstage/plugin-auth-backend` plugin also comes with a built-in resolver that can be used if desired.
The `emailMatchingUserEntityProfileEmail` identifies users by matching their Bitbucket Server email address to the email
address of `User` entities in the catalog. Note that you must populate your catalog with matching entities or users will
not be able to sign in with this resolver.
+43 -12
View File
@@ -5,6 +5,13 @@ sidebar_label: Bitbucket Server
description: Adding Bitbucket Server OAuth as an authentication provider in Backstage
---
:::info
This documentation is written for [the new backend system](../../backend-system/index.md) which is the default since Backstage
[version 1.24](../../releases/v1.24.0.md). If you are still on the old backend
system, you may want to read [its own article](./provider--old.md)
instead, and [consider migrating](../../backend-system/building-backends/08-migrating.md)!
:::
The Backstage `core-plugin-api` package comes with a Bitbucket Server authentication provider that can authenticate
users using Bitbucket Server. This does **NOT** work with Bitbucket Cloud.
@@ -34,19 +41,43 @@ The Bitbucket Server provider is a structure with two configuration keys:
- `clientId`: The client ID that was generated by Bitbucket, e.g. `b0f868455c15dcdff5c5fb5d173ae684`.
- `clientSecret`: The client secret tied to the generated client ID.
### Resolvers
This provider includes several resolvers out of the box that you can use:
- `emailMatchingUserEntityProfileEmail`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found it will throw a `NotFoundError`.
- `emailLocalPartMatchingUserEntityName`: Matches the [local part](https://en.wikipedia.org/wiki/Email_address#Local-part) of the email address from the auth provider with the User entity that has a matching `name`. If no match is found it will throw a `NotFoundError`.
:::note Note
The resolvers will be tried in order, but will only be skipped if they throw a `NotFoundError`.
:::
If these resolvers do not fit your needs you can build a custom resolver, this is covered in the [Building Custom Resolvers](../identity-resolver.md#building-custom-resolvers) section of the Sign-in Identities and Resolvers documentation.
## Backend Installation
To add the provider to the backend we will first need to install the package by running this command:
```bash title="from your Backstage root directory"
yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-bitbucket-server-provider
```
Then we will need to add this line:
```ts title="packages/backend/src/index.ts"
//...
backend.add(import('@backstage/plugin-auth-backend'));
// highlight-add-start
backend.add(
import('@backstage/plugin-auth-backend-module-bitbucket-server-provider'),
);
// highlight-add-end
//...
```
## Adding the provider to the Backstage frontend
To add the provider to the frontend, add the `bitbucketServerAuthApi` reference and `SignInPage` component as shown
in [Adding the provider to the sign-in page](../index.md#sign-in-configuration).
## Using Bitbucket Server for sign-in
In order to use the Bitbucket Server provider for sign-in, you must configure it with a `signIn.resolver`. See
the [Sign-In Resolver documentation](../identity-resolver.md) for more details on how this is done. Note that for the
Bitbucket Server provider, you'll want to use `bitbucketServer` as the provider ID,
and `providers.bitbucketServer.create` for the provider factory.
The `@backstage/plugin-auth-backend` plugin also comes with a built-in resolver that can be used if desired.
The `emailMatchingUserEntityProfileEmail` identifies users by matching their Bitbucket Server email address to the email
address of `User` entities in the catalog. Note that you must populate your catalog with matching entities or users will
not be able to sign in with this resolver.
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,8 @@
# Auth Module: Bitbucket Server Provider
This module provides an Bitbucket Server auth provider implementation for `@backstage/plugin-auth-backend`.
## Links
- [Repository](https://gitlab.com/backstage/backstage/tree/master/plugins/auth-backend-module-bitbucket-server-provider)
- [Backstage Project Homepage](https://backstage.io)
@@ -0,0 +1,45 @@
## API Report File for "@backstage/plugin-auth-backend-module-bitbucket-server-provider"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { PassportProfile } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
// @public (undocumented)
const authModuleBitbucketServerProvider: BackendFeature;
export default authModuleBitbucketServerProvider;
// @public (undocumented)
export const bitbucketServerAuthenticator: OAuthAuthenticator<
{
helper: PassportOAuthAuthenticatorHelper;
host: string;
},
PassportProfile
>;
// @public (undocumented)
export type BitbucketServerOAuthResult = {
fullProfile: PassportProfile;
params: {
scope: string;
access_token?: string;
token_type?: string;
expires_in?: number;
};
accessToken: string;
refreshToken?: string;
};
// @public
export namespace bitbucketServerSignInResolvers {
const emailMatchingUserEntityProfileEmail: SignInResolverFactory<
BitbucketServerOAuthResult,
unknown
>;
}
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth-backend-module-bitbucket-server-provider
title: '@backstage/plugin-auth-backend-module-bitbucket-server-provider'
description: The bitbucket-server-provider backend module for the auth plugin.
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: maintainers
@@ -0,0 +1,34 @@
/*
* Copyright 2024 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 interface Config {
auth?: {
providers?: {
/** @visibility frontend */
bitbucketServer?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
host: string;
callbackUrl?: string;
};
};
};
};
}
@@ -0,0 +1,26 @@
/*
* Copyright 2024 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 { createBackend } from '@backstage/backend-defaults';
import authPlugin from '@backstage/plugin-auth-backend';
import authModuleBitbucketServerProvider from '../src';
const backend = createBackend();
backend.add(authPlugin);
backend.add(authModuleBitbucketServerProvider);
backend.start();
@@ -0,0 +1,52 @@
{
"name": "@backstage/plugin-auth-backend-module-bitbucket-server-provider",
"version": "0.0.0",
"description": "The bitbucket-server-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
"pluginId": "auth",
"pluginPackage": "@backstage/plugin-auth-backend"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend-module-bitbucket-server-provider"
},
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist",
"config.d.ts"
],
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"start": "backstage-cli package start",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"node-fetch": "^2.7.0",
"passport": "^0.7.0",
"passport-oauth2": "^1.6.1"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@types/passport-oauth2": "^1.4.15",
"supertest": "^6.3.3"
},
"configSchema": "config.d.ts"
}
@@ -0,0 +1,93 @@
/*
* Copyright 2024 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 { Strategy as OAuth2Strategy, VerifyCallback } from 'passport-oauth2';
import {
createOAuthAuthenticator,
PassportOAuthAuthenticatorHelper,
PassportProfile,
} from '@backstage/plugin-auth-node';
import { fetchProfile } from './helpers';
/** @public */
export const bitbucketServerAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
initialize({ callbackUrl, config }) {
const clientID = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const host = config.getString('host');
const callbackURL = config.getOptionalString('callbackUrl') ?? callbackUrl;
const helper = PassportOAuthAuthenticatorHelper.from(
new OAuth2Strategy(
{
clientID,
clientSecret,
callbackURL,
authorizationURL: `https://${host}/rest/oauth2/latest/authorize`,
tokenURL: `https://${host}/rest/oauth2/latest/token`,
},
(
accessToken: string,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: VerifyCallback,
) => {
done(
undefined,
{ fullProfile, params, accessToken },
{ refreshToken },
);
},
),
);
return { helper, host };
},
async start(input, { helper }) {
return helper.start(input, {
accessType: 'offline',
prompt: 'consent',
});
},
async authenticate(input, { helper, host }) {
const result = await helper.authenticate(input);
// The OAuth2 strategy does not return a user profile, so we fetch it manually
const fullProfile = await fetchProfile({
host,
accessToken: result.session.accessToken,
});
return { ...result, fullProfile };
},
async refresh(input, { helper, host }) {
const result = await helper.refresh(input);
// The OAuth2 strategy does not return a user profile, so we fetch it manually
const fullProfile = await fetchProfile({
host,
accessToken: result.session.accessToken,
});
return { ...result, fullProfile };
},
});
@@ -0,0 +1,83 @@
/*
* Copyright 2024 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 fetch from 'node-fetch';
import { PassportProfile } from '@backstage/plugin-auth-node';
export async function fetchProfile(options: {
host: string;
accessToken: string;
}): Promise<PassportProfile> {
const { host, accessToken } = options;
// Get current user name
let whoAmIResponse;
try {
whoAmIResponse = await fetch(
`https://${host}/plugins/servlet/applinks/whoami`,
{
headers: {
Authorization: `Bearer ${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://${host}/rest/api/latest/users/${username}?avatarSize=256`,
{
headers: {
Authorization: `Bearer ${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();
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://${host}${user.avatarUrl}` }];
}
return passportProfile;
}
@@ -0,0 +1,26 @@
/*
* Copyright 2024 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.
*/
/**
* The bitbucket-server-provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export { bitbucketServerAuthenticator } from './authenticator';
export { authModuleBitbucketServerProvider as default } from './module';
export { bitbucketServerSignInResolvers } from './resolvers';
export { type BitbucketServerOAuthResult } from './types';
@@ -0,0 +1,81 @@
/*
* Copyright 2024 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 authPlugin from '@backstage/plugin-auth-backend';
import { decodeOAuthState } from '@backstage/plugin-auth-node';
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { authModuleBitbucketServerProvider } from './module';
import request from 'supertest';
describe('authModuleBitbucketServerProvider', () => {
it('should start', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleBitbucketServerProvider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
bitbucketServer: {
development: {
clientId: 'cliendId',
clientSecret: 'clientSecret',
host: 'bitbucket.org',
},
},
},
},
},
}),
],
});
const agent = request.agent(server);
const res = await agent.get(
'/api/auth/bitbucketServer/start?env=development',
);
expect(res.status).toEqual(302);
const nonceCookie = agent.jar.getCookie('bitbucketServer-nonce', {
domain: 'localhost',
path: '/api/auth/bitbucketServer/handler',
script: false,
secure: false,
});
expect(nonceCookie).toBeDefined();
const startUrl = new URL(res.get('location'));
expect(startUrl.origin).toBe('https://bitbucket.org');
expect(startUrl.pathname).toBe('/rest/oauth2/latest/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
client_id: 'cliendId',
redirect_uri: `http://localhost:${server.port()}/api/auth/bitbucketServer/handler/frame`,
state: expect.any(String),
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
});
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2024 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 { createBackendModule } from '@backstage/backend-plugin-api';
import {
authProvidersExtensionPoint,
commonSignInResolvers,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import { bitbucketServerAuthenticator } from './authenticator';
import { bitbucketServerSignInResolvers } from './resolvers';
/** @public */
export const authModuleBitbucketServerProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'bitbucket-server-provider',
register(reg) {
reg.registerInit({
deps: {
providers: authProvidersExtensionPoint,
},
async init({ providers }) {
providers.registerProvider({
providerId: 'bitbucketServer',
factory: createOAuthProviderFactory({
authenticator: bitbucketServerAuthenticator,
signInResolverFactories: {
...bitbucketServerSignInResolvers,
...commonSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,51 @@
/*
* Copyright 2024 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 {
createSignInResolverFactory,
SignInInfo,
} from '@backstage/plugin-auth-node';
import { BitbucketServerOAuthResult } from './types';
/**
* Available sign-in resolvers for the Bitbucket Server auth provider.
*
* @public
*/
export namespace bitbucketServerSignInResolvers {
/**
* Looks up the user by matching their email to the entity email.
*/
export const emailMatchingUserEntityProfileEmail =
createSignInResolverFactory({
create() {
return async (info: SignInInfo<BitbucketServerOAuthResult>, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error(
'Login failed, user profile does not contain an email',
);
}
return ctx.signInWithCatalogUser({
filter: {
'spec.profile.email': profile.email,
},
});
};
},
});
}
@@ -0,0 +1,32 @@
/*
* Copyright 2024 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 { PassportProfile } from '@backstage/plugin-auth-node';
/**
* @public
*/
export type BitbucketServerOAuthResult = {
fullProfile: PassportProfile;
params: {
scope: string;
access_token?: string;
token_type?: string;
expires_in?: number;
};
accessToken: string;
refreshToken?: string;
};
+3 -4
View File
@@ -14,6 +14,7 @@ import { AwsAlbResult as AwsAlbResult_2 } from '@backstage/plugin-auth-backend-m
import { AzureEasyAuthResult } from '@backstage/plugin-auth-backend-module-azure-easyauth-provider';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { BackstageSignInResult } from '@backstage/plugin-auth-node';
import { bitbucketServerSignInResolvers } from '@backstage/plugin-auth-backend-module-bitbucket-server-provider';
import { CacheService } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { ClientAuthResponse } from '@backstage/plugin-auth-node';
@@ -108,7 +109,7 @@ export type BitbucketPassportProfile = Profile & {
};
};
// @public (undocumented)
// @public @deprecated (undocumented)
export type BitbucketServerOAuthResult = {
fullProfile: Profile;
params: {
@@ -442,9 +443,7 @@ export const providers: Readonly<{
}
| undefined,
) => AuthProviderFactory_2;
resolvers: Readonly<{
emailMatchingUserEntityProfileEmail: () => SignInResolver_2<BitbucketServerOAuthResult>;
}>;
resolvers: Readonly<bitbucketServerSignInResolvers>;
}>;
cfAccess: Readonly<{
create: (options: {
+1
View File
@@ -53,6 +53,7 @@
"@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-azure-easyauth-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-bitbucket-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-bitbucket-server-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-cloudflare-access-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
@@ -1,390 +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 * as helpers from '../../lib/passport/PassportStrategyHelper';
import { makeProfileInfo } from '../../lib/passport';
import {
bitbucketServer,
BitbucketServerAuthProvider,
BitbucketServerOAuthResult,
} from './provider';
import { setupServer } from 'msw/node';
import { registerMswTestHooks } from '@backstage/backend-test-utils';
import { rest } from 'msw';
import { AuthResolverContext } from '@backstage/plugin-auth-node';
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();
registerMswTestHooks(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();
registerMswTestHooks(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);
});
});
});
@@ -14,40 +14,28 @@
* 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, OAuthStartResponse } 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';
import {
AuthResolverContext,
createOAuthProviderFactory,
SignInResolver,
} from '@backstage/plugin-auth-node';
import {
bitbucketServerAuthenticator,
bitbucketServerSignInResolvers,
} from '@backstage/plugin-auth-backend-module-bitbucket-server-provider';
import { OAuthProviderOptions } from '../../lib/oauth';
import {
adaptLegacyOAuthHandler,
adaptLegacyOAuthSignInResolver,
} from '../../lib/legacy';
import { AuthHandler } from '../types';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
type PrivateInfo = {
refreshToken: string;
};
/** @public */
/**
* @public
* @deprecated The Bitbucket Server auth provider was extracted to `@backstage/plugin-auth-backend-module-bitbucket-server-provider`.
*/
export type BitbucketServerOAuthResult = {
fullProfile: PassportProfile;
params: {
@@ -60,6 +48,10 @@ export type BitbucketServerOAuthResult = {
refreshToken?: string;
};
/**
* @public
* @deprecated The Bitbucket Server auth provider was extracted to `@backstage/plugin-auth-backend-module-bitbucket-server-provider`.
*/
export type BitbucketServerAuthProviderOptions = OAuthProviderOptions & {
host: string;
authorizationUrl: string;
@@ -69,176 +61,6 @@ export type BitbucketServerAuthProviderOptions = OAuthProviderOptions & {
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();
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?: {
/**
@@ -257,48 +79,11 @@ export const bitbucketServer = createAuthProviderIntegration({
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,
return createOAuthProviderFactory({
authenticator: bitbucketServerAuthenticator,
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
});
},
resolvers: bitbucketServerSignInResolvers,
});
+19
View File
@@ -4850,6 +4850,24 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-bitbucket-server-provider@workspace:^, @backstage/plugin-auth-backend-module-bitbucket-server-provider@workspace:plugins/auth-backend-module-bitbucket-server-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-bitbucket-server-provider@workspace:plugins/auth-backend-module-bitbucket-server-provider"
dependencies:
"@backstage/backend-defaults": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@types/passport-oauth2": ^1.4.15
node-fetch: ^2.7.0
passport: ^0.7.0
passport-oauth2: ^1.6.1
supertest: ^6.3.3
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:^, @backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:plugins/auth-backend-module-cloudflare-access-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:plugins/auth-backend-module-cloudflare-access-provider"
@@ -5121,6 +5139,7 @@ __metadata:
"@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-azure-easyauth-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-bitbucket-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-bitbucket-server-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-cloudflare-access-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^"