feat: migrate bitbucket server provider to the new backend system
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user