core-plugin-api: Refactor IdentityApi

Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Johan Haals <johan.haals@gmail.com>
Co-authored-by: blam <ben@blam.sh>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-11-24 11:59:59 +01:00
committed by blam
parent afa656ae6b
commit 6304c8f947
11 changed files with 543 additions and 149 deletions
@@ -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();
}
}
@@ -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,76 @@
/*
* 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 {
constructor(private readonly result: SignInResult) {}
getUserId(): string {
return this.result.userId;
}
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 ?? [sub],
};
}
async getCredentials(): Promise<{ token?: string | undefined }> {
const token = await this.result.getIdToken?.();
return { token };
}
async signOut(): Promise<void> {
return this.result.signOut?.();
}
}
@@ -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;
}
}
+74 -15
View File
@@ -43,12 +43,15 @@ import {
AppThemeApi,
ConfigApi,
featureFlagsApiRef,
IdentityApi,
identityApiRef,
BackstagePlugin,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} from '@backstage/core-plugin-api';
import { GuestUserIdentity } from '../apis/implementations/IdentityApi/GuestUserIdentity';
import { LegacyUserIdentity } from '../apis/implementations/IdentityApi/LegacyUserIdentity';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
childDiscoverer,
@@ -66,7 +69,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,
@@ -189,7 +192,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 +347,23 @@ 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;
const setLegacyResult = (result: SignInResult) => {
setIdentityApi(new LegacyUserIdentity(result));
};
if (!identityApi) {
return (
<Component
onResult={setLegacyResult}
onSignInSuccess={setIdentityApi}
/>
);
}
return <Component onResult={setResult} />;
this.appIdentityProxy.setTarget(identityApi);
return children;
};
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
@@ -360,13 +372,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(new GuestUserIdentity());
return (
<RouterComponent>
@@ -430,7 +436,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
@@ -485,3 +491,56 @@ export class AppManager implements BackstageApp {
}
}
}
interface FooPropsV1 {
foo: () => undefined;
}
interface FooPropsV2 {
foo: () => undefined;
bar: () => undefined;
}
// type FooProps = {
// foo: () => undefined
// } | {
// foo: () => undefined
// bar: () => undefined
// }
interface CreateDerpOptions {
components: {
Foo: (props: FooPropsV1 | FooPropsV2) => JSX.Element;
};
}
interface Derp {
getComponents(): {
Foo: (props: FooPropsV1) => JSX.Element;
};
}
function createDerp(options: CreateDerpOptions): Derp {
return { getComponents: () => options.components };
}
function CustomFoo(props: FooPropsV1) {
return <div>{props.foo()}</div>;
}
function NewCustomFoo(props: FooPropsV2) {
return (
<div>
{props.foo()} {props.bar()}
</div>
);
}
const derp = createDerp({
components: {
Foo: NewCustomFoo,
},
});
const { Foo } = derp.getComponents();
const _foo = <Foo foo={() => undefined} />;
+8
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 IdentityApi} to the {@link SignInPageProps.onSignInSuccess} instead.
*/
export type SignInResult = {
/**
@@ -70,8 +72,14 @@ export type SignInResult = {
export type SignInPageProps = {
/**
* Set the sign-in result for the app. This should only be called once.
* @deprecated use {@link SignInPageProps.onSignInSuccess} instead.
*/
onResult(result: SignInResult): void;
/**
* Set the IdentityApi on successful sign in. This should only be called once.
*/
onSignInSuccess(identityApi: IdentityApi): void;
};
/**
@@ -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';
@@ -87,7 +88,7 @@ export const MultiSignInPage = ({
};
export const SingleSignInPage = ({
onResult,
onSignInSuccess,
provider,
auto,
}: SingleSignInPageProps) => {
@@ -105,54 +106,47 @@ 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.from({
identity: identityResponse.identity,
authApi,
profile,
}),
);
} catch (err: any) {
// User closed the sign-in modal
setError(err);
setShowLoginPage(true);
}
};
useMount(() => login({ checkExisting: true }));
return showLoginPage ? (
@@ -0,0 +1,91 @@
/*
* 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,
} from '@backstage/core-plugin-api';
export class UserIdentity implements IdentityApi {
static from(options: {
identity: BackstageUserIdentity;
authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi;
/**
* Passing a profile synchronously allows the deprecated `getProfile` method to be
* called by consumers of the {@link IdentityApi}. If you do not have any consumers
* of that method than 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;
}) {
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,
) {}
getUserId(): string {
const ref = this.identity.userEntityRef;
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref);
if (!match) {
throw new TypeError(`Invalid user entity reference "${ref}"`);
}
return match[3];
}
async getIdToken(): Promise<string | undefined> {
const identity = await this.authApi.getBackstageIdentity();
return identity!.token;
}
getProfile(): ProfileInfo {
if (!this.profile) {
throw new Error(
'The identity API does not implement synchronous profile fetching, use getProfileInfo() instead',
);
}
return this.profile;
}
async getProfileInfo(): Promise<ProfileInfo> {
const profile = await this.authApi.getProfile();
return profile!;
}
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
return this.identity;
}
async getCredentials(): Promise<{ token?: string | undefined }> {
const identity = await this.authApi.getBackstageIdentity();
return { token: identity!.token };
}
async signOut(): Promise<void> {
return this.authApi.signOut();
}
}
@@ -16,6 +16,34 @@
import { ApiRef, createApiRef } from '../system';
import { ProfileInfo } from './auth';
/*
- [ ] IdentityApi getProfile, make async
- [ ] BackstageIdentity (settle or remove)
- [ ] Evolution plan for utility APIs
*/
/**
* User identity information within Backstage.
*
* @public
*/
export type BackstageUserIdentity = {
type: 'user';
/**
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
/**
* The Identity API used to identify and get information about the signed in user.
*
@@ -26,25 +54,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.getIdentity} 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,7 +160,31 @@ export type BackstageIdentityApi = {
*/
getBackstageIdentity(
options?: AuthRequestOptions,
): Promise<BackstageIdentity | undefined>;
): Promise<BackstageIdentityResponse | undefined>;
};
/**
* User identity information within Backstage.
*
* @public
*/
export type BackstageUserIdentity = {
/**
* The type of identity that this structure represents. In the frontend app
* this will currently always be 'user'.
*/
type: 'user';
/**
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
/**
@@ -168,23 +192,31 @@ export type BackstageIdentityApi = {
*
* @public
*/
export type BackstageIdentity = {
export type BackstageIdentityResponse = {
/**
* The backstage user ID.
*
* @deprecated The identity is now provided via the `identity` field instead.
*/
id: string;
/**
* @deprecated This is deprecated, use `token` instead.
*/
idToken: string;
/**
* The token used to authenticate the user within Backstage.
*/
token: string;
/**
* Identity information derived from the token.
*/
identity: BackstageUserIdentity;
};
/**
* @public
* @deprecated use {@link BackstageIdentityResponse} instead.
*/
export type BackstageIdentity = BackstageIdentityResponse;
/**
* Profile information of the user.
*
+33 -12
View File
@@ -140,32 +140,51 @@ export type AuthResponse<ProviderInfo> = {
backstageIdentity?: BackstageIdentity;
};
export type BackstageIdentity = {
/**
* @public
*/
export type BackstageIdentityResponse = {
/**
* An opaque ID that uniquely identifies the user within Backstage.
*
* This is typically the same as the user entity `metadata.name`.
*
* @deprecated Use the `identity` field instead
*/
id: string;
/**
* This is deprecated, use `token` instead.
* @deprecated
*/
idToken?: string;
/**
* The token used to authenticate the user within Backstage.
*/
token?: string;
/**
* The entity that the user is represented by within Backstage.
*
* This entity may or may not exist within the Catalog, and it can be used
* to read and store additional metadata about the user.
*
* @deprecated Use the `identity` field instead.
*/
entity?: Entity;
/**
* The token used to authenticate the user within Backstage.
*/
token: string;
/**
* A plaintext description of the identity that is encapsulated within the token.
*/
identity?: {
type: 'user';
/**
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
};
/**
@@ -173,6 +192,8 @@ export type BackstageIdentity = {
*
* It is also temporarily used as the profile of the signed-in user's Backstage
* identity, but we want to replace that with data from identity and/org catalog service
*
* @public
*/
export type ProfileInfo = {
/**