Merge pull request #8283 from backstage/mob/identity-api

core-plugin-api: stabilize IdentityApi
This commit is contained in:
Johan Haals
2021-12-08 14:29:03 +01:00
committed by GitHub
56 changed files with 1163 additions and 327 deletions
+3 -2
View File
@@ -39,6 +39,7 @@ import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api';
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
import { googleAuthApiRef } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
import { OAuthApi } from '@backstage/core-plugin-api';
import { OAuthRequestApi } from '@backstage/core-plugin-api';
@@ -574,10 +575,10 @@ export type SamlSession = {
// @public
export type SignInPageProps = {
onResult(result: SignInResult): void;
onSignInSuccess(identityApi: IdentityApi): void;
};
// @public
// @public @deprecated
export type SignInResult = {
userId: string;
profile: ProfileInfo;
@@ -0,0 +1,90 @@
/*
* 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 {
IdentityApi,
ProfileInfo,
BackstageUserIdentity,
} from '@backstage/core-plugin-api';
function mkError(thing: string) {
return new Error(
`Tried to access IdentityApi ${thing} before app was loaded`,
);
}
/**
* Implementation of the connection between the App-wide IdentityApi
* and sign-in page.
*/
export class AppIdentityProxy implements IdentityApi {
private target?: IdentityApi;
// This is called by the app manager once the sign-in page provides us with an implementation
setTarget(identityApi: IdentityApi) {
this.target = identityApi;
}
getUserId(): string {
if (!this.target) {
throw mkError('getUserId');
}
return this.target.getUserId();
}
getProfile(): ProfileInfo {
if (!this.target) {
throw mkError('getProfile');
}
return this.target.getProfile();
}
async getProfileInfo(): Promise<ProfileInfo> {
if (!this.target) {
throw mkError('getProfileInfo');
}
return this.target.getProfileInfo();
}
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
if (!this.target) {
throw mkError('getBackstageIdentity');
}
return this.target.getBackstageIdentity();
}
async getCredentials(): Promise<{ token?: string | undefined }> {
if (!this.target) {
throw mkError('getCredentials');
}
return this.target.getCredentials();
}
async getIdToken(): Promise<string | undefined> {
if (!this.target) {
throw mkError('getIdToken');
}
return this.target.getIdToken();
}
async signOut(): Promise<void> {
if (!this.target) {
throw mkError('signOut');
}
await this.target.signOut();
location.reload();
}
}
@@ -1,85 +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 { IdentityApi, ProfileInfo } from '@backstage/core-plugin-api';
import { SignInResult } from './types';
/**
* Implementation of the connection between the App-wide IdentityApi
* and sign-in page.
*/
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private profile?: ProfileInfo;
private idTokenFunc?: () => Promise<string>;
private signOutFunc?: () => Promise<void>;
getUserId(): string {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi userId before app was loaded',
);
}
return this.userId!;
}
getProfile(): ProfileInfo {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi profile before app was loaded',
);
}
return this.profile!;
}
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idTokenFunc?.();
}
async signOut(): Promise<void> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi signOutFunc before app was loaded',
);
}
await this.signOutFunc?.();
location.reload();
}
// This is indirectly called by the sign-in page to continue into the app.
setSignInResult(result: SignInResult) {
if (this.hasIdentity) {
return;
}
if (!result.userId) {
throw new Error('Invalid sign-in result, userId not set');
}
if (!result.profile) {
throw new Error('Invalid sign-in result, profile not set');
}
this.hasIdentity = true;
this.userId = result.userId;
this.profile = result.profile;
this.idTokenFunc = result.getIdToken;
this.signOutFunc = result.signOut;
}
}
+11 -16
View File
@@ -43,12 +43,14 @@ import {
AppThemeApi,
ConfigApi,
featureFlagsApiRef,
IdentityApi,
identityApiRef,
BackstagePlugin,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} from '@backstage/core-plugin-api';
import { UserIdentity } from '@backstage/core-components';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
childDiscoverer,
@@ -66,7 +68,7 @@ import { RoutingProvider } from '../routing/RoutingProvider';
import { RouteTracker } from '../routing/RouteTracker';
import { validateRoutes } from '../routing/validation';
import { AppContextProvider } from './AppContext';
import { AppIdentity } from './AppIdentity';
import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
import {
AppComponents,
AppConfigLoader,
@@ -75,7 +77,6 @@ import {
AppRouteBinder,
BackstageApp,
SignInPageProps,
SignInResult,
} from './types';
import { AppThemeProvider } from './AppThemeProvider';
import { defaultConfigLoader } from './defaultConfigLoader';
@@ -189,7 +190,7 @@ export class AppManager implements BackstageApp {
private readonly defaultApis: Iterable<AnyApiFactory>;
private readonly bindRoutes: AppOptions['bindRoutes'];
private readonly identityApi = new AppIdentity();
private readonly appIdentityProxy = new AppIdentityProxy();
private readonly apiFactoryRegistry: ApiFactoryRegistry;
constructor(options: AppOptions) {
@@ -344,14 +345,14 @@ export class AppManager implements BackstageApp {
component: ComponentType<SignInPageProps>;
children: ReactElement;
}) => {
const [result, setResult] = useState<SignInResult>();
const [identityApi, setIdentityApi] = useState<IdentityApi>();
if (result) {
this.identityApi.setSignInResult(result);
return children;
if (!identityApi) {
return <Component onSignInSuccess={setIdentityApi} />;
}
return <Component onResult={setResult} />;
this.appIdentityProxy.setTarget(identityApi);
return children;
};
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
@@ -360,13 +361,7 @@ export class AppManager implements BackstageApp {
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
});
this.appIdentityProxy.setTarget(UserIdentity.createGuest());
return (
<RouterComponent>
@@ -430,7 +425,7 @@ export class AppManager implements BackstageApp {
this.apiFactoryRegistry.register('static', {
api: identityApiRef,
deps: {},
factory: () => this.identityApi,
factory: () => this.appIdentityProxy,
});
// It's possible to replace the feature flag API, but since we must have at least
+4 -2
View File
@@ -25,6 +25,7 @@ import {
SubRouteRef,
ExternalRouteRef,
PluginOutput,
IdentityApi,
} from '@backstage/core-plugin-api';
import { AppConfig } from '@backstage/config';
@@ -42,6 +43,7 @@ export type BootErrorPageProps = {
* The outcome of signing in on the sign-in page.
*
* @public
* @deprecated replaced by passing the {@link @backstage/core-plugin-api#IdentityApi} to the {@link SignInPageProps.onSignInSuccess} instead.
*/
export type SignInResult = {
/**
@@ -69,9 +71,9 @@ export type SignInResult = {
*/
export type SignInPageProps = {
/**
* Set the sign-in result for the app. This should only be called once.
* Set the IdentityApi on successful sign in. This should only be called once.
*/
onResult(result: SignInResult): void;
onSignInSuccess(identityApi: IdentityApi): void;
};
/**
+31
View File
@@ -9,6 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { BackstageIdentityApi } from '@backstage/core-plugin-api';
import { BackstagePalette } from '@backstage/theme';
import { BackstageTheme } from '@backstage/theme';
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button';
import { CardHeaderProps } from '@material-ui/core/CardHeader';
import { Column } from '@material-table/core';
@@ -20,6 +21,7 @@ import { CSSProperties } from 'react';
import { ElementType } from 'react';
import { ErrorInfo } from 'react';
import { IconComponent } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { LinearProgressProps } from '@material-ui/core/LinearProgress';
import { LinkProps as LinkProps_2 } from '@material-ui/core/Link';
import { LinkProps as LinkProps_3 } from 'react-router-dom';
@@ -27,6 +29,7 @@ import MaterialBreadcrumbs from '@material-ui/core/Breadcrumbs';
import { MaterialTableProps } from '@material-table/core';
import { NavLinkProps } from 'react-router-dom';
import { Overrides } from '@material-ui/core/styles/overrides';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
@@ -35,6 +38,7 @@ import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { SessionApi } from '@backstage/core-plugin-api';
import { SignInPageProps } from '@backstage/core-plugin-api';
import { SignInResult } from '@backstage/core-plugin-api';
import { SparklinesLineProps } from 'react-sparklines';
import { SparklinesProps } from 'react-sparklines';
import { StyledComponentProps } from '@material-ui/core/styles';
@@ -2314,6 +2318,33 @@ export function useQueryParamState<T>(
// @public (undocumented)
export function UserIcon(props: IconComponentProps): JSX.Element;
// @public
export class UserIdentity implements IdentityApi {
static create(options: {
identity: BackstageUserIdentity;
authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi;
profile?: ProfileInfo;
}): IdentityApi;
static createGuest(): IdentityApi;
static fromLegacy(result: SignInResult): IdentityApi;
// (undocumented)
getBackstageIdentity(): Promise<BackstageUserIdentity>;
// (undocumented)
getCredentials(): Promise<{
token?: string | undefined;
}>;
// (undocumented)
getIdToken(): Promise<string | undefined>;
// (undocumented)
getProfile(): ProfileInfo;
// (undocumented)
getProfileInfo(): Promise<ProfileInfo>;
// (undocumented)
getUserId(): string;
// (undocumented)
signOut(): Promise<void>;
}
// Warning: (ae-missing-release-tag) "useSupportConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -0,0 +1,60 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IdentityApi,
ProfileInfo,
BackstageUserIdentity,
} from '@backstage/core-plugin-api';
export class GuestUserIdentity implements IdentityApi {
getUserId(): string {
return 'guest';
}
async getIdToken(): Promise<string | undefined> {
return undefined;
}
getProfile(): ProfileInfo {
return {
email: 'guest@example.com',
displayName: 'Guest',
};
}
async getProfileInfo(): Promise<ProfileInfo> {
return {
email: 'guest@example.com',
displayName: 'Guest',
};
}
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
const userEntityRef = `user:default/guest`;
return {
type: 'user',
userEntityRef,
ownershipEntityRefs: [userEntityRef],
};
}
async getCredentials(): Promise<{ token?: string | undefined }> {
return {};
}
async signOut(): Promise<void> {}
}
@@ -0,0 +1,65 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
BackstageUserIdentity,
IdentityApi,
ProfileInfo,
} from '@backstage/core-plugin-api';
export class IdentityApiSignOutProxy implements IdentityApi {
private constructor(
private readonly config: {
identityApi: IdentityApi;
signOut: IdentityApi['signOut'];
},
) {}
static from(config: {
identityApi: IdentityApi;
signOut: IdentityApi['signOut'];
}): IdentityApi {
return new IdentityApiSignOutProxy(config);
}
getUserId(): string {
return this.config.identityApi.getUserId();
}
getIdToken(): Promise<string | undefined> {
return this.config.identityApi.getIdToken();
}
getProfile(): ProfileInfo {
return this.config.identityApi.getProfile();
}
getProfileInfo(): Promise<ProfileInfo> {
return this.config.identityApi.getProfileInfo();
}
getBackstageIdentity(): Promise<BackstageUserIdentity> {
return this.config.identityApi.getBackstageIdentity();
}
getCredentials(): Promise<{ token?: string | undefined }> {
return this.config.identityApi.getCredentials();
}
signOut(): Promise<void> {
return this.config.signOut();
}
}
@@ -0,0 +1,80 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IdentityApi,
ProfileInfo,
BackstageUserIdentity,
SignInResult,
} from '@backstage/core-plugin-api';
function parseJwtPayload(token: string) {
const [_header, payload, _signature] = token.split('.');
return JSON.parse(atob(payload));
}
export class LegacyUserIdentity implements IdentityApi {
private constructor(private readonly result: SignInResult) {}
getUserId(): string {
return this.result.userId;
}
static fromResult(result: SignInResult): LegacyUserIdentity {
return new LegacyUserIdentity(result);
}
async getIdToken(): Promise<string | undefined> {
return this.result.getIdToken?.();
}
getProfile(): ProfileInfo {
return this.result.profile;
}
async getProfileInfo(): Promise<ProfileInfo> {
return this.result.profile;
}
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
const token = await this.getIdToken();
if (!token) {
const userEntityRef = `user:default/${this.getUserId()}`;
return {
type: 'user',
userEntityRef,
ownershipEntityRefs: [userEntityRef],
};
}
const { sub, ent } = parseJwtPayload(token);
return {
type: 'user',
userEntityRef: sub,
ownershipEntityRefs: ent ?? [],
};
}
async getCredentials(): Promise<{ token?: string | undefined }> {
const token = await this.result.getIdToken?.();
return { token };
}
async signOut(): Promise<void> {
return this.result.signOut?.();
}
}
@@ -15,11 +15,12 @@
*/
import {
BackstageIdentity,
BackstageIdentityResponse,
configApiRef,
SignInPageProps,
useApi,
} from '@backstage/core-plugin-api';
import { UserIdentity } from './UserIdentity';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
@@ -49,7 +50,7 @@ type SingleSignInPageProps = SignInPageProps & {
export type Props = MultiSignInPageProps | SingleSignInPageProps;
export const MultiSignInPage = ({
onResult,
onSignInSuccess,
providers = [],
title,
align = 'left',
@@ -60,7 +61,7 @@ export const MultiSignInPage = ({
const signInProviders = getSignInProviders(providers);
const [loading, providerElements] = useSignInProviders(
signInProviders,
onResult,
onSignInSuccess,
);
if (loading) {
@@ -87,9 +88,9 @@ export const MultiSignInPage = ({
};
export const SingleSignInPage = ({
onResult,
provider,
auto,
onSignInSuccess,
}: SingleSignInPageProps) => {
const classes = useStyles();
const authApi = useApi(provider.apiRef);
@@ -105,47 +106,42 @@ export const SingleSignInPage = ({
type LoginOpts = { checkExisting?: boolean; showPopup?: boolean };
const login = async ({ checkExisting, showPopup }: LoginOpts) => {
try {
let identity: BackstageIdentity | undefined;
let identityResponse: BackstageIdentityResponse | undefined;
if (checkExisting) {
// Do an initial check if any logged-in session exists
identity = await authApi.getBackstageIdentity({
identityResponse = await authApi.getBackstageIdentity({
optional: true,
});
}
// If no session exists, show the sign-in page
if (!identity && (showPopup || auto)) {
if (!identityResponse && (showPopup || auto)) {
// Unless auto is set to true, this step should not happen.
// When user intentionally clicks the Sign In button, autoShowPopup is set to true
setShowLoginPage(true);
identity = await authApi.getBackstageIdentity({
identityResponse = await authApi.getBackstageIdentity({
instantPopup: true,
});
if (!identity) {
if (!identityResponse) {
throw new Error(
`The ${provider.title} provider is not configured to support sign-in`,
);
}
}
if (!identity) {
if (!identityResponse) {
setShowLoginPage(true);
return;
}
const profile = await authApi.getProfile();
onResult({
userId: identity!.id,
profile: profile!,
getIdToken: () => {
return authApi
.getBackstageIdentity()
.then(i => i!.token ?? i!.idToken);
},
signOut: async () => {
await authApi.signOut();
},
});
onSignInSuccess(
UserIdentity.create({
identity: identityResponse.identity,
authApi,
profile,
}),
);
} catch (err: any) {
// User closed the sign-in modal
setError(err);
@@ -0,0 +1,83 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BackstageUserIdentity, ProfileInfo } from '@backstage/core-plugin-api';
import { UserIdentity } from './UserIdentity';
describe('UserIdentity', () => {
it('should cache a successful response from the AuthApi for getProfile', async () => {
const mockIdentity: BackstageUserIdentity = {
type: 'user',
userEntityRef: 'user:default/blam',
ownershipEntityRefs: [],
};
const mockProfileInfo: ProfileInfo = {
displayName: 'Blam',
email: 'blob@boop.com',
};
const mockAuthApi: any = {
getProfile: jest.fn().mockResolvedValue(mockProfileInfo),
};
const userIdentity = UserIdentity.create({
authApi: mockAuthApi,
identity: mockIdentity,
});
await userIdentity.getProfileInfo();
await userIdentity.getProfileInfo();
const response = await userIdentity.getProfileInfo();
expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(1);
expect(response).toEqual(mockProfileInfo);
});
it('should not cache failures for the AuthApi for getProfile', async () => {
const mockIdentity: BackstageUserIdentity = {
type: 'user',
userEntityRef: 'user:default/blam',
ownershipEntityRefs: [],
};
const mockProfileInfo: ProfileInfo = {
displayName: 'Blam',
email: 'blob@boop.com',
};
const mockAuthApi: any = {
getProfile: jest
.fn()
.mockRejectedValueOnce(new Error('boop'))
.mockResolvedValueOnce(mockProfileInfo),
};
const userIdentity = UserIdentity.create({
authApi: mockAuthApi,
identity: mockIdentity,
});
await expect(() => userIdentity.getProfileInfo()).rejects.toThrow('boop');
const response = await userIdentity.getProfileInfo();
expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(2);
expect(response).toEqual(mockProfileInfo);
});
});
@@ -0,0 +1,142 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IdentityApi,
ProfileInfo,
ProfileInfoApi,
BackstageUserIdentity,
BackstageIdentityApi,
SessionApi,
SignInResult,
} from '@backstage/core-plugin-api';
import { GuestUserIdentity } from './GuestUserIdentity';
import { LegacyUserIdentity } from './LegacyUserIdentity';
/**
* An implementation of the IdentityApi that is constructed using
* various backstage user identity representations.
*
* @public
*/
export class UserIdentity implements IdentityApi {
private profilePromise?: Promise<ProfileInfo>;
/**
* Creates a new IdentityApi that acts as a Guest User.
*
* @public
*/
static createGuest(): IdentityApi {
return new GuestUserIdentity();
}
/**
* Creates a new IdentityApi using a legacy SignInResult object.
*
* @public
*/
static fromLegacy(result: SignInResult): IdentityApi {
return LegacyUserIdentity.fromResult(result);
}
/**
* Creates a new IdentityApi implementation using a user identity
* and an auth API that will be used to request backstage tokens.
*
* @public
*/
static create(options: {
identity: BackstageUserIdentity;
authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi;
/**
* Passing a profile synchronously allows the deprecated `getProfile` method to be
* called by consumers of the {@link @backstage/core-plugin-api#IdentityApi}. If you
* do not have any consumers of that method then this is safe to leave out.
*
* @deprecated Only provide this if you have plugins that call the synchronous `getProfile` method, which is also deprecated.
*/
profile?: ProfileInfo;
}): IdentityApi {
return new UserIdentity(options.identity, options.authApi, options.profile);
}
private constructor(
private readonly identity: BackstageUserIdentity,
private readonly authApi: ProfileInfoApi &
BackstageIdentityApi &
SessionApi,
private readonly profile?: ProfileInfo,
) {}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */
getUserId(): string {
const ref = this.identity.userEntityRef;
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref);
if (!match) {
throw new TypeError(`Invalid user entity reference "${ref}"`);
}
return match[3];
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */
async getIdToken(): Promise<string | undefined> {
const identity = await this.authApi.getBackstageIdentity();
return identity!.token;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */
getProfile(): ProfileInfo {
if (!this.profile) {
throw new Error(
'The identity API does not implement synchronous profile fetching, use getProfileInfo() instead',
);
}
return this.profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */
async getProfileInfo(): Promise<ProfileInfo> {
if (this.profilePromise) {
return await this.profilePromise;
}
try {
this.profilePromise = this.authApi.getProfile() as Promise<ProfileInfo>;
return await this.profilePromise;
} catch (ex) {
this.profilePromise = undefined;
throw ex;
}
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
return this.identity;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */
async getCredentials(): Promise<{ token?: string | undefined }> {
const identity = await this.authApi.getBackstageIdentity();
return { token: identity!.token };
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */
async signOut(): Promise<void> {
return this.authApi.signOut();
}
}
@@ -26,17 +26,18 @@ import {
errorApiRef,
} from '@backstage/core-plugin-api';
import { ForwardedError } from '@backstage/errors';
import { UserIdentity } from './UserIdentity';
const Component: ProviderComponent = ({ onResult }) => {
const Component: ProviderComponent = ({ onSignInSuccess }) => {
const auth0AuthApi = useApi(auth0AuthApiRef);
const errorApi = useApi(errorApiRef);
const handleLogin = async () => {
try {
const identity = await auth0AuthApi.getBackstageIdentity({
const identityResponse = await auth0AuthApi.getBackstageIdentity({
instantPopup: true,
});
if (!identity) {
if (!identityResponse) {
throw new Error(
'The Auth0 provider is not configured to support sign-in',
);
@@ -44,15 +45,13 @@ const Component: ProviderComponent = ({ onResult }) => {
const profile = await auth0AuthApi.getProfile();
onResult({
userId: identity!.id,
profile: profile!,
getIdToken: () =>
auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
signOut: async () => {
await auth0AuthApi.signOut();
},
});
onSignInSuccess(
UserIdentity.create({
identity: identityResponse.identity,
authApi: auth0AuthApi,
profile,
}),
);
} catch (error) {
errorApi.post(new ForwardedError('Auth0 login failed', error));
}
@@ -77,25 +76,20 @@ const Component: ProviderComponent = ({ onResult }) => {
const loader: ProviderLoader = async apis => {
const auth0AuthApi = apis.get(auth0AuthApiRef)!;
const identity = await auth0AuthApi.getBackstageIdentity({
const identityResponse = await auth0AuthApi.getBackstageIdentity({
optional: true,
});
if (!identity) {
if (!identityResponse) {
return undefined;
}
const profile = await auth0AuthApi.getProfile();
return {
userId: identity.id,
profile: profile!,
getIdToken: () =>
auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
signOut: async () => {
await auth0AuthApi.signOut();
},
};
return UserIdentity.create({
identity: identityResponse.identity,
authApi: auth0AuthApi,
profile,
});
};
export const auth0Provider: SignInProvider = { Component, loader };
@@ -27,36 +27,33 @@ import {
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
import { GridItem } from './styles';
import { ForwardedError } from '@backstage/errors';
import { UserIdentity } from './UserIdentity';
const Component: ProviderComponent = ({ config, onResult }) => {
const Component: ProviderComponent = ({ config, onSignInSuccess }) => {
const { apiRef, title, message } = config as SignInProviderConfig;
const authApi = useApi(apiRef);
const errorApi = useApi(errorApiRef);
const handleLogin = async () => {
try {
const identity = await authApi.getBackstageIdentity({
const identityResponse = await authApi.getBackstageIdentity({
instantPopup: true,
});
if (!identity) {
if (!identityResponse) {
throw new Error(
`The ${title} provider is not configured to support sign-in`,
);
}
const profile = await authApi.getProfile();
onResult({
userId: identity!.id,
profile: profile!,
getIdToken: () => {
return authApi
.getBackstageIdentity()
.then(i => i!.token ?? i!.idToken);
},
signOut: async () => {
await authApi.signOut();
},
});
onSignInSuccess(
UserIdentity.create({
identity: identityResponse.identity,
profile,
authApi,
}),
);
} catch (error) {
errorApi.post(new ForwardedError('Login failed', error));
}
@@ -82,25 +79,21 @@ const Component: ProviderComponent = ({ config, onResult }) => {
const loader: ProviderLoader = async (apis, apiRef) => {
const authApi = apis.get(apiRef)!;
const identity = await authApi.getBackstageIdentity({
const identityResponse = await authApi.getBackstageIdentity({
optional: true,
});
if (!identity) {
if (!identityResponse) {
return undefined;
}
const profile = await authApi.getProfile();
return {
userId: identity.id,
profile: profile!,
getIdToken: () =>
authApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
signOut: async () => {
await authApi.signOut();
},
};
return UserIdentity.create({
identity: identityResponse.identity,
profile,
authApi,
});
};
export const commonProvider: SignInProvider = { Component, loader };
@@ -26,6 +26,7 @@ import isEmpty from 'lodash/isEmpty';
import { InfoCard } from '../InfoCard/InfoCard';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
import { GridItem } from './styles';
import { UserIdentity } from './UserIdentity';
// accept base64url format according to RFC7515 (https://tools.ietf.org/html/rfc7515#section-3)
const ID_TOKEN_REGEX = /^[a-z0-9_\-]+\.[a-z0-9_\-]+\.[a-z0-9_\-]+$/i;
@@ -60,7 +61,7 @@ const asInputRef = (renderResult: UseFormRegisterReturn) => {
};
};
const Component: ProviderComponent = ({ onResult }) => {
const Component: ProviderComponent = ({ onSignInSuccess }) => {
const classes = useFormStyles();
const { register, handleSubmit, formState } = useForm<Data>({
mode: 'onChange',
@@ -68,14 +69,15 @@ const Component: ProviderComponent = ({ onResult }) => {
const { errors } = formState;
const handleResult = ({ userId, idToken }: Data) => {
onResult({
userId,
profile: {
email: `${userId}@example.com`,
},
getIdToken: idToken ? async () => idToken : undefined,
});
const handleResult = ({ userId }: Data) => {
onSignInSuccess(
UserIdentity.fromLegacy({
userId,
profile: {
email: `${userId}@example.com`,
},
}),
);
};
return (
@@ -20,16 +20,9 @@ import Button from '@material-ui/core/Button';
import { InfoCard } from '../InfoCard/InfoCard';
import { GridItem } from './styles';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
import { GuestUserIdentity } from './GuestUserIdentity';
const result = {
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
};
const Component: ProviderComponent = ({ onResult }) => (
const Component: ProviderComponent = ({ onSignInSuccess }) => (
<GridItem>
<InfoCard
title="Guest"
@@ -38,7 +31,7 @@ const Component: ProviderComponent = ({ onResult }) => (
<Button
color="primary"
variant="outlined"
onClick={() => onResult(result)}
onClick={() => onSignInSuccess(new GuestUserIdentity())}
>
Enter
</Button>
@@ -56,7 +49,7 @@ const Component: ProviderComponent = ({ onResult }) => (
);
const loader: ProviderLoader = async () => {
return result;
return new GuestUserIdentity();
};
export const guestProvider: SignInProvider = { Component, loader };
@@ -18,3 +18,4 @@ export type { SignInProviderConfig } from './types';
export { SignInPage } from './SignInPage';
export type { SignInPageClassKey } from './styles';
export type { CustomProviderClassKey } from './customProvider';
export { UserIdentity } from './UserIdentity';
@@ -17,10 +17,10 @@
import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react';
import {
SignInPageProps,
SignInResult,
useApi,
useApiHolder,
errorApiRef,
IdentityApi,
} from '@backstage/core-plugin-api';
import {
IdentityProviders,
@@ -30,6 +30,7 @@ import {
import { commonProvider } from './commonProvider';
import { guestProvider } from './guestProvider';
import { customProvider } from './customProvider';
import { IdentityApiSignOutProxy } from './IdentityApiSignOutProxy';
const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider';
@@ -81,7 +82,7 @@ export function getSignInProviders(
export const useSignInProviders = (
providers: SignInProviderType,
onResult: SignInPageProps['onResult'],
onSignInSuccess: SignInPageProps['onSignInSuccess'],
) => {
const errorApi = useApi(errorApiRef);
const apiHolder = useApiHolder();
@@ -89,16 +90,18 @@ export const useSignInProviders = (
// This decorates the result with sign out logic from this hook
const handleWrappedResult = useCallback(
(result: SignInResult) => {
onResult({
...result,
signOut: async () => {
localStorage.removeItem(PROVIDER_STORAGE_KEY);
await result.signOut?.();
},
});
(identityApi: IdentityApi) => {
onSignInSuccess(
IdentityApiSignOutProxy.from({
identityApi,
signOut: async () => {
localStorage.removeItem(PROVIDER_STORAGE_KEY);
await identityApi.signOut?.();
},
}),
);
},
[onResult],
[onSignInSuccess],
);
// In this effect we check if the user has already selected an existing login
@@ -151,7 +154,14 @@ export const useSignInProviders = (
return () => {
didCancel = true;
};
}, [loading, errorApi, onResult, apiHolder, providers, handleWrappedResult]);
}, [
loading,
errorApi,
onSignInSuccess,
apiHolder,
providers,
handleWrappedResult,
]);
// This renders all available sign-in providers
const elements = useMemo(
@@ -161,7 +171,7 @@ export const useSignInProviders = (
const { Component } = provider.components;
const handleResult = (result: SignInResult) => {
const handleSignInSuccess = (result: IdentityApi) => {
localStorage.setItem(PROVIDER_STORAGE_KEY, provider.id);
handleWrappedResult(result);
@@ -171,7 +181,7 @@ export const useSignInProviders = (
<Component
key={provider.id}
config={provider.config!}
onResult={handleResult}
onSignInSuccess={handleSignInSuccess}
/>
);
}),
@@ -17,12 +17,12 @@
import { ComponentType } from 'react';
import {
SignInPageProps,
SignInResult,
ApiHolder,
ApiRef,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi,
IdentityApi,
} from '@backstage/core-plugin-api';
export type SignInProviderConfig = {
@@ -41,7 +41,7 @@ export type ProviderComponent = ComponentType<
export type ProviderLoader = (
apis: ApiHolder,
apiRef: ApiRef<ProfileInfoApi & BackstageIdentityApi & SessionApi>,
) => Promise<SignInResult | undefined>;
) => Promise<IdentityApi | undefined>;
export type SignInProvider = {
Component: ProviderComponent;
+26 -10
View File
@@ -10,6 +10,7 @@ import { BackstageTheme } from '@backstage/theme';
import { ComponentType } from 'react';
import { Config } from '@backstage/config';
import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api';
import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api';
import { Observable as Observable_2 } from '@backstage/types';
import { Observer as Observer_2 } from '@backstage/types';
import { ProfileInfo as ProfileInfo_2 } from '@backstage/core-plugin-api';
@@ -233,18 +234,21 @@ export type AuthRequestOptions = {
instantPopup?: boolean;
};
// @public
export type BackstageIdentity = {
id: string;
idToken: string;
token: string;
};
// @public @deprecated
export type BackstageIdentity = BackstageIdentityResponse;
// @public
export type BackstageIdentityApi = {
getBackstageIdentity(
options?: AuthRequestOptions,
): Promise<BackstageIdentity | undefined>;
): Promise<BackstageIdentityResponse | undefined>;
};
// @public
export type BackstageIdentityResponse = {
id: string;
token: string;
identity: BackstageUserIdentity;
};
// @public
@@ -261,6 +265,13 @@ export type BackstagePlugin<
externalRoutes: ExternalRoutes;
};
// @public
export type BackstageUserIdentity = {
type: 'user';
userEntityRef: string;
ownershipEntityRefs: string[];
};
// @public
export const bitbucketAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
@@ -528,8 +539,13 @@ export type IconComponent = ComponentType<{
// @public
export type IdentityApi = {
getUserId(): string;
getProfile(): ProfileInfo;
getIdToken(): Promise<string | undefined>;
getProfile(): ProfileInfo;
getProfileInfo(): Promise<ProfileInfo>;
getBackstageIdentity(): Promise<BackstageUserIdentity>;
getCredentials(): Promise<{
token?: string;
}>;
signOut(): Promise<void>;
};
@@ -742,10 +758,10 @@ export enum SessionState {
// @public
export type SignInPageProps = {
onResult(result: SignInResult): void;
onSignInSuccess(identityApi: IdentityApi_2): void;
};
// @public
// @public @deprecated
export type SignInResult = {
userId: string;
profile: ProfileInfo_2;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { ProfileInfo } from './auth';
import { BackstageUserIdentity, ProfileInfo } from './auth';
/**
* The Identity API used to identify and get information about the signed in user.
@@ -26,25 +26,45 @@ export type IdentityApi = {
* The ID of the signed in user. This ID is not meant to be presented to the user, but used
* as an opaque string to pass on to backends or use in frontend logic.
*
* TODO: The intention of the user ID is to be able to tie the user to an identity
* that is known by the catalog and/or identity backend. It should for example
* be possible to fetch all owned components using this ID.
* @deprecated use {@link IdentityApi.getBackstageIdentity} instead.
*/
getUserId(): string;
/**
* The profile of the signed in user.
*/
getProfile(): ProfileInfo;
/**
* An OpenID Connect ID Token which proves the identity of the signed in user.
*
* The ID token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*
* @deprecated use {@link IdentityApi.getCredentials} instead.
*/
getIdToken(): Promise<string | undefined>;
/**
* The profile of the signed in user.
*
* @deprecated use {@link IdentityApi.getProfileInfo} instead.
*/
getProfile(): ProfileInfo;
/**
* The profile of the signed in user.
*/
getProfileInfo(): Promise<ProfileInfo>;
/**
* User identity information within Backstage.
*/
getBackstageIdentity(): Promise<BackstageUserIdentity>;
/**
* Provides credentials in the form of a token which proves the identity of the signed in user.
*
* The token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*/
getCredentials(): Promise<{ token?: string }>;
/**
* Sign out the current user
*/
@@ -160,31 +160,65 @@ export type BackstageIdentityApi = {
*/
getBackstageIdentity(
options?: AuthRequestOptions,
): Promise<BackstageIdentity | undefined>;
): Promise<BackstageIdentityResponse | undefined>;
};
/**
* A (user id, token) pair.
* User identity information within Backstage.
*
* @public
*/
export type BackstageIdentity = {
export type BackstageUserIdentity = {
/**
* The backstage user ID.
* The type of identity that this structure represents. In the frontend app
* this will currently always be 'user'.
*/
id: string;
type: 'user';
/**
* @deprecated This is deprecated, use `token` instead.
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
idToken: string;
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
/**
* Token and Identity response, with the users claims in the Identity.
*
* @public
*/
export type BackstageIdentityResponse = {
/**
* The backstage user ID.
*
* @deprecated The identity is now provided via the `identity` field instead.
*/
id: string;
/**
* The token used to authenticate the user within Backstage.
*/
token: string;
/**
* Identity information derived from the token.
*/
identity: BackstageUserIdentity;
};
/**
* The old exported symbol for {@link BackstageIdentityResponse}.
*
* @public
* @deprecated use {@link BackstageIdentityResponse} instead.
*/
export type BackstageIdentity = BackstageIdentityResponse;
/**
* Profile information of the user.
*