Merge remote-tracking branch 'upstream/master' into easyauth

This commit is contained in:
Alex Crome
2023-02-14 14:47:01 +00:00
1156 changed files with 29593 additions and 4200 deletions
+13
View File
@@ -1,5 +1,18 @@
# @backstage/plugin-auth-backend
## 0.17.5-next.2
### Patch Changes
- Updated dependencies
- @backstage/backend-common@0.18.2-next.2
- @backstage/catalog-model@1.2.0-next.1
- @backstage/plugin-auth-node@0.2.11-next.2
- @backstage/catalog-client@1.3.1-next.1
- @backstage/config@1.0.6
- @backstage/errors@1.1.4
- @backstage/types@1.0.2
## 0.17.5-next.1
### Patch Changes
+32 -2
View File
@@ -7,7 +7,7 @@
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
import { BackstageSignInResult } from '@backstage/plugin-auth-node';
import { CacheClient } from '@backstage/backend-plugin-api';
import { CacheService } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
@@ -132,6 +132,19 @@ export type BitbucketPassportProfile = Profile & {
};
};
// @public (undocumented)
export type BitbucketServerOAuthResult = {
fullProfile: Profile;
params: {
scope: string;
access_token?: string;
token_type?: string;
expires_in?: number;
};
accessToken: string;
refreshToken?: string;
};
// @public
export class CatalogIdentityClient {
constructor(options: { catalogApi: CatalogApi; tokenManager: TokenManager });
@@ -480,13 +493,30 @@ export const providers: Readonly<{
userIdMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
}>;
}>;
bitbucketServer: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<BitbucketServerOAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<BitbucketServerOAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: Readonly<{
emailMatchingUserEntityProfileEmail: () => SignInResolver<BitbucketServerOAuthResult>;
}>;
}>;
cfAccess: Readonly<{
create: (options: {
authHandler?: AuthHandler<CloudflareAccessResult> | undefined;
signIn: {
resolver: SignInResolver<CloudflareAccessResult>;
};
cache?: CacheClient | undefined;
cache?: CacheService | undefined;
}) => AuthProviderFactory;
resolvers: Readonly<{
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-auth-backend",
"description": "A Backstage backend plugin that handles authentication",
"version": "0.17.5-next.1",
"version": "0.17.5-next.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -0,0 +1,18 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { bitbucketServer } from './provider';
export type { BitbucketServerOAuthResult } from './provider';
@@ -0,0 +1,390 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { makeProfileInfo } from '../../lib/passport';
import { AuthResolverContext } from '../types';
import {
bitbucketServer,
BitbucketServerAuthProvider,
BitbucketServerOAuthResult,
} from './provider';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
jest.mock('../../lib/passport/PassportStrategyHelper', () => {
return {
...jest.requireActual('../../lib/passport/PassportStrategyHelper'),
executeFrameHandlerStrategy: jest.fn(),
executeRefreshTokenStrategy: jest.fn(),
executeFetchUserProfileStrategy: jest.fn(),
};
});
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<
() => Promise<{
result: BitbucketServerOAuthResult;
privateInfo: { refreshToken?: string };
}>
>;
const passportProfile = {
id: '123',
username: 'john.doe',
provider: 'bitubcketServer',
displayName: 'John Doe',
emails: [{ value: 'john@doe.com' }],
photos: [{ value: 'https://bitbucket.org/user/123/avatar' }],
};
const mockHost = 'bitbucket.org';
const mockBaseUrl = `https://${mockHost}`;
const whoAmIHandler = (options?: { fail?: boolean; value?: string }) =>
rest.get(
`${mockBaseUrl}/plugins/servlet/applinks/whoami`,
(_req, res, ctx) => {
if (options?.fail) {
res.networkError('error');
}
return res(
ctx.status(200),
ctx.set('X-Ausername', options?.value ?? passportProfile.username),
);
},
);
const getUserHandler = (options?: {
fail?: boolean;
status?: number;
avatarUrl?: string;
noDisplayName?: boolean;
noUserName?: boolean;
}) =>
rest.get(
`${mockBaseUrl}/rest/api/latest/users/${passportProfile.username}`,
(_req, res, ctx) => {
if (options?.fail) {
res.networkError('error');
}
return res(
ctx.status(options?.status ?? 200),
ctx.json({
name: options?.noUserName ? undefined : 'john.doe',
emailAddress: 'john@doe.com',
id: 123,
displayName: options?.noDisplayName ? undefined : 'John Doe',
active: true,
slug: 'john.doe',
type: 'NORMAL',
links: {
self: [
{
href: 'https://bitbucket.org/users/john.doe',
},
],
},
avatarUrl: options?.avatarUrl ?? '/user/123/avatar',
}),
);
},
);
describe('BitbucketServerAuthProvider', () => {
const provider = new BitbucketServerAuthProvider({
resolverContext: {
signInWithCatalogUser: jest.fn(info => {
return {
token: `token-for-user:${info.filter['spec.profile.email']}`,
};
}),
} as unknown as AuthResolverContext,
signInResolver:
bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(),
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
callbackUrl: 'mock',
clientId: 'mock',
clientSecret: 'mock',
host: mockHost,
authorizationUrl: 'mock',
tokenUrl: 'mock',
});
describe('when transforming to type OAuthResponse', () => {
const server = setupServer();
setupRequestMockHandlers(server);
it('should map to a valid response', async () => {
server.use(whoAmIHandler(), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should throw if whoami fails', async () => {
server.use(whoAmIHandler({ fail: true }), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the username of the logged in user`,
);
});
it('should throw if whoami returns an invalid response', async () => {
server.use(whoAmIHandler({ value: '' }), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the username of the logged in user`,
);
});
it('should throw if get user fails', async () => {
server.use(whoAmIHandler(), getUserHandler({ fail: true }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the user '${passportProfile.username}'`,
);
});
it('should throw if get user is not ok', async () => {
server.use(whoAmIHandler(), getUserHandler({ status: 500 }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
await expect(provider.handler({} as any)).rejects.toThrow(
`Failed to retrieve the user '${passportProfile.username}'`,
);
});
it('should not set an avatar url if not given', async () => {
server.use(whoAmIHandler(), getUserHandler({ avatarUrl: '' }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should fallback to the username if no displayName is given', async () => {
server.use(whoAmIHandler(), getUserHandler({ noDisplayName: true }));
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'john.doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should fallback to the user id if no name is given', async () => {
server.use(
whoAmIHandler(),
getUserHandler({ noDisplayName: true, noUserName: true }),
);
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const expected = {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: '123',
picture: 'https://bitbucket.org/user/123/avatar',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
});
describe('when authenticating', () => {
const server = setupServer();
setupRequestMockHandlers(server);
it('should forward the refresh token', async () => {
server.use(whoAmIHandler(), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: { refreshToken: 'refresh-token' },
});
const response = await provider.handler({} as any);
const expected = {
response: {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
},
refreshToken: 'refresh-token',
};
expect(response).toEqual(expected);
});
it('should forward a new refresh token on refresh', async () => {
server.use(whoAmIHandler(), getUserHandler());
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const params = { scope: 'REPO_READ' };
const mockRefreshToken = jest.spyOn(
helpers,
'executeRefreshTokenStrategy',
) as unknown as jest.MockedFunction<() => Promise<{}>>;
mockRefreshToken.mockResolvedValueOnce({
accessToken,
refreshToken: 'dont-forget-to-send-refresh',
params,
});
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile: passportProfile, accessToken, params },
privateInfo: { refreshToken: 'refresh-token' },
});
const expected = {
response: {
backstageIdentity: {
token: 'token-for-user:john@doe.com',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'REPO_READ',
},
profile: {
email: 'john@doe.com',
displayName: 'John Doe',
picture: 'https://bitbucket.org/user/123/avatar',
},
},
refreshToken: 'dont-forget-to-send-refresh',
};
const response = await provider.refresh({ scope: 'REPO_WRITE' } as any);
expect(response).toEqual(expected);
});
});
});
@@ -0,0 +1,305 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
} from '../../lib/oauth';
import { Strategy as OAuth2Strategy, VerifyCallback } from 'passport-oauth2';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/passport';
import {
AuthHandler,
AuthResolverContext,
OAuthStartResponse,
SignInResolver,
} from '../types';
import express from 'express';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { Profile as PassportProfile } from 'passport';
import { commonByEmailResolver } from '../resolvers';
import fetch from 'node-fetch';
type PrivateInfo = {
refreshToken: string;
};
/** @public */
export type BitbucketServerOAuthResult = {
fullProfile: PassportProfile;
params: {
scope: string;
access_token?: string;
token_type?: string;
expires_in?: number;
};
accessToken: string;
refreshToken?: string;
};
export type BitbucketServerAuthProviderOptions = OAuthProviderOptions & {
host: string;
authorizationUrl: string;
tokenUrl: string;
authHandler: AuthHandler<BitbucketServerOAuthResult>;
signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
resolverContext: AuthResolverContext;
};
export class BitbucketServerAuthProvider implements OAuthHandlers {
private readonly signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
private readonly authHandler: AuthHandler<BitbucketServerOAuthResult>;
private readonly resolverContext: AuthResolverContext;
private readonly strategy: OAuth2Strategy;
private readonly host: string;
constructor(options: BitbucketServerAuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.resolverContext = options.resolverContext;
this.strategy = new OAuth2Strategy(
{
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
},
(
accessToken: string,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: VerifyCallback,
) => {
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
},
);
this.host = options.host;
}
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
return await executeRedirectStrategy(req, this.strategy, {
accessType: 'offline',
prompt: 'consent',
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { result, privateInfo } = await executeFrameHandlerStrategy<
BitbucketServerOAuthResult,
PrivateInfo
>(req, this.strategy);
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(
req: OAuthRefreshRequest,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this.strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this.strategy,
accessToken,
);
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(
result: BitbucketServerOAuthResult,
): Promise<OAuthResponse> {
// The OAuth2 strategy does not return a user profile -> let's fetch it before calling the auth handler
result.fullProfile = await this.fetchProfile(result);
const { profile } = await this.authHandler(result, this.resolverContext);
let backstageIdentity = undefined;
if (this.signInResolver) {
backstageIdentity = await this.signInResolver(
{ result, profile },
this.resolverContext,
);
}
return {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
backstageIdentity,
};
}
private async fetchProfile(
result: BitbucketServerOAuthResult,
): Promise<PassportProfile> {
// Get current user name
let whoAmIResponse;
try {
whoAmIResponse = await fetch(
`https://${this.host}/plugins/servlet/applinks/whoami`,
{
headers: {
Authorization: `Bearer ${result.accessToken}`,
},
},
);
} catch (e) {
throw new Error(`Failed to retrieve the username of the logged in user`);
}
// A response.ok check here would be worthless as the Bitbucket API always returns 200 OK for this call
const username = whoAmIResponse.headers.get('X-Ausername');
if (!username) {
throw new Error(`Failed to retrieve the username of the logged in user`);
}
let userResponse;
try {
userResponse = await fetch(
`https://${this.host}/rest/api/latest/users/${username}?avatarSize=256`,
{
headers: {
Authorization: `Bearer ${result.accessToken}`,
},
},
);
} catch (e) {
throw new Error(`Failed to retrieve the user '${username}'`);
}
if (!userResponse.ok) {
throw new Error(`Failed to retrieve the user '${username}'`);
}
const user = (await userResponse.json()) as any;
const passportProfile = {
provider: 'bitbucketServer',
id: user.id.toString(),
displayName: user.displayName,
username: user.name,
emails: [
{
value: user.emailAddress,
},
],
} as PassportProfile;
if (user.avatarUrl) {
passportProfile.photos = [
{ value: `https://${this.host}${user.avatarUrl}` },
];
}
return passportProfile;
}
}
export const bitbucketServer = createAuthProviderIntegration({
create(options?: {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<BitbucketServerOAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<BitbucketServerOAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const host = envConfig.getString('host');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://${host}/rest/oauth2/latest/authorize`;
const tokenUrl = `https://${host}/rest/oauth2/latest/token`;
const authHandler: AuthHandler<BitbucketServerOAuthResult> =
options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const provider = new BitbucketServerAuthProvider({
callbackUrl,
clientId,
clientSecret,
host,
authorizationUrl,
tokenUrl,
authHandler,
signInResolver: options?.signIn?.resolver,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
resolvers: {
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail:
(): SignInResolver<BitbucketServerOAuthResult> => commonByEmailResolver,
},
});
@@ -83,6 +83,7 @@ const mockCacheClient = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
withOptions: jest.fn(),
};
jest.mock('jose');
@@ -19,6 +19,7 @@ export type {
BitbucketOAuthResult,
BitbucketPassportProfile,
} from './bitbucket';
export type { BitbucketServerOAuthResult } from './bitbucketServer';
export type {
CloudflareAccessClaims,
CloudflareAccessGroup,
@@ -31,6 +31,7 @@ import { okta } from './okta';
import { onelogin } from './onelogin';
import { saml } from './saml';
import { AuthProviderFactory } from './types';
import { bitbucketServer } from './bitbucketServer';
/**
* All built-in auth provider integrations.
@@ -42,6 +43,7 @@ export const providers = Object.freeze({
auth0,
awsAlb,
bitbucket,
bitbucketServer,
cfAccess,
gcpIap,
github,
@@ -76,5 +78,6 @@ export const defaultAuthProviderFactories: {
onelogin: onelogin.create(),
awsalb: awsAlb.create(),
bitbucket: bitbucket.create(),
bitbucketServer: bitbucketServer.create(),
atlassian: atlassian.create(),
};