Merge pull request #8695 from backstage/feature/add-gcp-iap-auth-provider-frontend
auth: implement frontend parts of iap-like sign-in
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Add a `ProxiedSignInPage` that can be used e.g. for GCP IAP and AWS ALB
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: provider
|
||||
title: Google Identity-Aware Proxy Provider
|
||||
sidebar_label: Google IAP
|
||||
# prettier-ignore
|
||||
description: Adding Google Identity-Aware Proxy as an authentication provider in Backstage
|
||||
---
|
||||
|
||||
Backstage allows offloading the responsibility of authenticating users to the
|
||||
Google HTTPS Load Balancer & [IAP](https://cloud.google.com/iap), leveraging the
|
||||
authentication support on the latter.
|
||||
|
||||
This tutorial shows how to use authentication on an IAP sitting in front of
|
||||
Backstage.
|
||||
|
||||
It is assumed an IAP is already serving traffic in front of a Backstage instance
|
||||
configured to serve the frontend app from the backend.
|
||||
|
||||
## Configuration
|
||||
|
||||
Let's start by adding the following `auth` configuration in your
|
||||
`app-config.yaml` or `app-config.production.yaml` or similar:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
providers:
|
||||
gcp-iap:
|
||||
audience:
|
||||
'/projects/<project number>/global/backendServices/<backend service id>'
|
||||
```
|
||||
|
||||
You can find the project number and service ID in the Google Cloud Console.
|
||||
|
||||
This config section must be in place for the provider to load at all. Now let's
|
||||
add the provider itself.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
This provider is not enabled by default in the auth backend code, because
|
||||
besides the config section above, it also needs to be given one or more
|
||||
callbacks in actual code as well as described below.
|
||||
|
||||
Add a `providerFactories` entry to the router in
|
||||
`packages/backend/plugin/auth.ts`.
|
||||
|
||||
```ts
|
||||
import { createGcpIapProvider } from '@backstage/plugin-auth-backend';
|
||||
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
database,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return await createRouter({
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
discovery,
|
||||
providerFactories: {
|
||||
'gcp-iap': createGcpIapProvider({
|
||||
// Replace the auth handler if you want to customize the returned user
|
||||
// profile info (can be left out; the default implementation is shown
|
||||
// below which only returns the email). You may want to amend this code
|
||||
// with something that loads additional user profile data out of e.g.
|
||||
// GSuite or LDAP or similar.
|
||||
async authHandler({ iapToken }) {
|
||||
return { profile: { email: iapToken.email } };
|
||||
},
|
||||
signIn: {
|
||||
// You need to supply an identity resolver, that takes the profile
|
||||
// and the IAP token and produces the Backstage token with the
|
||||
// relevant user info.
|
||||
async resolver({ profile, result: { iapToken } }, ctx) {
|
||||
// Somehow compute the Backstage token claims. Just some dummy code
|
||||
// shown here, but you may want to query your LDAP server, or
|
||||
// GSuite or similar, based on the IAP token sub/email claims
|
||||
const id = `user:default/${iapToken.email.split('@')[0]}`;
|
||||
const fullEnt = ['group:default/team-name'];
|
||||
const token = await ctx.tokenIssuer.issueToken({
|
||||
claims: { sub: id, ent: fullEnt },
|
||||
});
|
||||
return { id, token };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Now the backend is ready to serve auth requests on the
|
||||
`/api/auth/gcp-iap/refresh` endpoint. All that's left is to update the frontend
|
||||
sign-in mechanism to poll that endpoint through the IAP, on the user's behalf.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
All Backstage apps need a `SignInPage` to be configured. Its purpose is to
|
||||
establish who the user is and what their identifying credentials are, blocking
|
||||
rendering the rest of the UI until that's complete, and then keeping those
|
||||
credentials fresh.
|
||||
|
||||
When using IAP Proxy authentication, the Backstage UI will only be loaded once
|
||||
the user has already successfully completely authenticated themselves with the
|
||||
IAP and has an active session, so we don't want to make the user have to go
|
||||
through a _second_ layer of authentication flows after that.
|
||||
|
||||
As such, we want to not display a sign-in page visually at all. Instead, we will
|
||||
pick a `SignInPage` implementation component which knows how to silently make
|
||||
requests to the backend provider we configured above, and just trusting its
|
||||
output to properly represent the current user. Luckily, Backstage comes with a
|
||||
component for this purpose out of the box.
|
||||
|
||||
Update your `createApp` call in `packages/app/src/App.tsx`, as follows.
|
||||
|
||||
```diff
|
||||
+import { ProxiedSignInPage } from '@backstage/core-components';
|
||||
|
||||
const app = createApp({
|
||||
components: {
|
||||
+ SignInPage: props => <ProxiedSignInPage {...props} provider="gcp-iap" />,
|
||||
```
|
||||
|
||||
After this, your app should be ready to leverage the Identity-Aware Proxy for
|
||||
authentication!
|
||||
@@ -752,6 +752,16 @@ export function Progress(
|
||||
props: PropsWithChildren<LinearProgressProps>,
|
||||
): JSX.Element;
|
||||
|
||||
// @public
|
||||
export const ProxiedSignInPage: (
|
||||
props: ProxiedSignInPageProps,
|
||||
) => JSX.Element | null;
|
||||
|
||||
// @public
|
||||
export type ProxiedSignInPageProps = SignInPageProps & {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "Ranker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.6",
|
||||
"remark-gfm": "^2.0.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
"zen-observable": "^0.8.15",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
@@ -92,7 +93,9 @@
|
||||
"@types/react-syntax-highlighter": "^13.5.2",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/zen-observable": "^0.8.0"
|
||||
"@types/zen-observable": "^0.8.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.35.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2022 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 { setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import {
|
||||
DEFAULTS,
|
||||
ProxiedSignInIdentity,
|
||||
tokenToExpiry,
|
||||
} from './ProxiedSignInIdentity';
|
||||
|
||||
const validBackstageTokenExpClaim = 1641216199;
|
||||
const validBackstageToken =
|
||||
'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJmcmViZW4iLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE2NDEyMTI1OTksImV4cCI6MTY0MTIxNjE5OSwiZW50IjpbInVzZXI6ZGVmYXVsdC9mcmViZW4iXX0.4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ';
|
||||
|
||||
describe('ProxiedSignInIdentity', () => {
|
||||
describe('tokenToExpiry', () => {
|
||||
beforeEach(() => jest.useFakeTimers('modern'));
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('handles undefined', async () => {
|
||||
expect(tokenToExpiry(undefined)).toEqual(
|
||||
new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles a valid token', async () => {
|
||||
expect(tokenToExpiry(validBackstageToken)).toEqual(
|
||||
new Date(
|
||||
validBackstageTokenExpClaim * 1000 - DEFAULTS.tokenExpiryMarginMillis,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles a token that has no exp', async () => {
|
||||
const [a, _b, c] = validBackstageToken.split('.');
|
||||
const botched = `${a}.${btoa(JSON.stringify({}))}.${c}`;
|
||||
expect(tokenToExpiry(botched)).toEqual(
|
||||
new Date(new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxiedSignInIdentity', () => {
|
||||
beforeEach(() => jest.useFakeTimers('modern'));
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
it('runs the happy path', async () => {
|
||||
const getBaseUrl = jest.fn();
|
||||
const serverCalled = jest.fn();
|
||||
|
||||
function makeToken() {
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 3600;
|
||||
return {
|
||||
providerInfo: {
|
||||
stuff: 1,
|
||||
},
|
||||
profile: {
|
||||
email: 'e',
|
||||
displayName: 'd',
|
||||
picture: 'p',
|
||||
},
|
||||
backstageIdentity: {
|
||||
id: 'i',
|
||||
token: [
|
||||
'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9',
|
||||
btoa(
|
||||
JSON.stringify({
|
||||
iss: 'http://localhost:7007/api/auth',
|
||||
sub: 'user:default/freben',
|
||||
aud: 'backstage',
|
||||
iat,
|
||||
exp,
|
||||
ent: ['group:default/my-team'],
|
||||
}),
|
||||
).replace(/=/g, ''),
|
||||
'4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ',
|
||||
].join('.'),
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'ue',
|
||||
ownershipEntityRefs: ['oe'],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
worker.events.on('request:match', serverCalled);
|
||||
worker.use(
|
||||
rest.get('http://example.com/api/auth/foo/refresh', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(makeToken()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const identity = new ProxiedSignInIdentity({
|
||||
provider: 'foo',
|
||||
discoveryApi: { getBaseUrl },
|
||||
});
|
||||
|
||||
getBaseUrl.mockResolvedValue('http://example.com/api/auth');
|
||||
|
||||
await identity.start(); // should not throw
|
||||
expect(getBaseUrl).toBeCalledTimes(1);
|
||||
expect(getBaseUrl).lastCalledWith('auth');
|
||||
expect(serverCalled).toBeCalledTimes(1);
|
||||
|
||||
await identity.getSessionAsync(); // no need to fetch again just yet
|
||||
expect(serverCalled).toBeCalledTimes(1);
|
||||
|
||||
// Use a fairly large margin (1000) since the iat and exp are clamped to
|
||||
// full seconds, but the "local current time" isn't
|
||||
jest.advanceTimersByTime(
|
||||
3600 * 1000 - DEFAULTS.tokenExpiryMarginMillis - 1000,
|
||||
);
|
||||
await identity.getSessionAsync(); // still no need to fetch again
|
||||
expect(serverCalled).toBeCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(1001);
|
||||
await identity.getSessionAsync(); // now the expiry has passed
|
||||
expect(serverCalled).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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,
|
||||
discoveryApiRef,
|
||||
IdentityApi,
|
||||
ProfileInfo,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { ProxiedSession, proxiedSessionSchema } from './types';
|
||||
|
||||
export const DEFAULTS = {
|
||||
// The amount of time between token refreshes, if we fail to get an actual
|
||||
// value out of the exp claim
|
||||
defaultTokenExpiryMillis: 5 * 60 * 1000,
|
||||
// The amount of time before the actual expiry of the Backstage token, that we
|
||||
// shall start trying to get a new one
|
||||
tokenExpiryMarginMillis: 5 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
// When the token expires, with some margin
|
||||
export function tokenToExpiry(jwtToken: string | undefined): Date {
|
||||
const fallback = new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis);
|
||||
if (!jwtToken) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const [_header, rawPayload, _signature] = jwtToken.split('.');
|
||||
const payload = JSON.parse(atob(rawPayload));
|
||||
if (typeof payload.exp !== 'number') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return new Date(payload.exp * 1000 - DEFAULTS.tokenExpiryMarginMillis);
|
||||
}
|
||||
|
||||
type ProxiedSignInIdentityOptions = {
|
||||
provider: string;
|
||||
discoveryApi: typeof discoveryApiRef.T;
|
||||
};
|
||||
|
||||
type State =
|
||||
| {
|
||||
type: 'empty';
|
||||
}
|
||||
| {
|
||||
type: 'fetching';
|
||||
promise: Promise<ProxiedSession>;
|
||||
previous: ProxiedSession | undefined;
|
||||
}
|
||||
| {
|
||||
type: 'active';
|
||||
session: ProxiedSession;
|
||||
expiresAt: Date;
|
||||
}
|
||||
| {
|
||||
type: 'failed';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
/**
|
||||
* An identity API that gets the user auth information solely based on a
|
||||
* provider's `/refresh` endpoint.
|
||||
*/
|
||||
export class ProxiedSignInIdentity implements IdentityApi {
|
||||
private readonly options: ProxiedSignInIdentityOptions;
|
||||
private readonly abortController: AbortController;
|
||||
private state: State;
|
||||
|
||||
constructor(options: ProxiedSignInIdentityOptions) {
|
||||
this.options = options;
|
||||
this.abortController = new AbortController();
|
||||
this.state = { type: 'empty' };
|
||||
}
|
||||
|
||||
async start() {
|
||||
// Try to make a first fetch, bubble up any errors to the caller
|
||||
await this.getSessionAsync();
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */
|
||||
getUserId(): string {
|
||||
const session = this.getSessionSync();
|
||||
return session.backstageIdentity.id;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */
|
||||
async getIdToken(): Promise<string | undefined> {
|
||||
const session = await this.getSessionAsync();
|
||||
return session.backstageIdentity.token;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */
|
||||
getProfile(): ProfileInfo {
|
||||
const session = this.getSessionSync();
|
||||
return session.profile;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */
|
||||
async getProfileInfo(): Promise<ProfileInfo> {
|
||||
const session = await this.getSessionAsync();
|
||||
return session.profile;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */
|
||||
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
|
||||
const session = await this.getSessionAsync();
|
||||
return session.backstageIdentity.identity;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */
|
||||
async getCredentials(): Promise<{ token?: string | undefined }> {
|
||||
const session = await this.getSessionAsync();
|
||||
return {
|
||||
token: session.backstageIdentity.token,
|
||||
};
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */
|
||||
async signOut(): Promise<void> {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
getSessionSync(): ProxiedSession {
|
||||
if (this.state.type === 'active') {
|
||||
return this.state.session;
|
||||
} else if (this.state.type === 'fetching' && this.state.previous) {
|
||||
return this.state.previous;
|
||||
}
|
||||
throw new Error('No session available. Try reloading your browser page.');
|
||||
}
|
||||
|
||||
async getSessionAsync(): Promise<ProxiedSession> {
|
||||
if (this.state.type === 'fetching') {
|
||||
return this.state.promise;
|
||||
} else if (
|
||||
this.state.type === 'active' &&
|
||||
new Date() < this.state.expiresAt
|
||||
) {
|
||||
return this.state.session;
|
||||
}
|
||||
|
||||
const previous =
|
||||
this.state.type === 'active' ? this.state.session : undefined;
|
||||
|
||||
const promise = this.fetchSession().then(
|
||||
session => {
|
||||
this.state = {
|
||||
type: 'active',
|
||||
session,
|
||||
expiresAt: tokenToExpiry(session.backstageIdentity.token),
|
||||
};
|
||||
return session;
|
||||
},
|
||||
error => {
|
||||
this.state = {
|
||||
type: 'failed',
|
||||
error,
|
||||
};
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
|
||||
this.state = {
|
||||
type: 'fetching',
|
||||
promise,
|
||||
previous,
|
||||
};
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async fetchSession(): Promise<ProxiedSession> {
|
||||
const baseUrl = await this.options.discoveryApi.getBaseUrl('auth');
|
||||
|
||||
// Note that we do not use the fetchApi here, since this all happens before
|
||||
// sign-in completes so there can be no automatic token injection and
|
||||
// similar.
|
||||
const response = await fetch(
|
||||
`${baseUrl}/${this.options.provider}/refresh`,
|
||||
{
|
||||
signal: this.abortController.signal,
|
||||
headers: { 'x-requested-with': 'XMLHttpRequest' },
|
||||
credentials: 'include',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
return proxiedSessionSchema.parse(await response.json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 {
|
||||
discoveryApiRef,
|
||||
SignInPageProps,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import React from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { ErrorPanel } from '../../components/ErrorPanel';
|
||||
import { Progress } from '../../components/Progress';
|
||||
import { ProxiedSignInIdentity } from './ProxiedSignInIdentity';
|
||||
|
||||
/**
|
||||
* Props for {@link ProxiedSignInPage}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ProxiedSignInPageProps = SignInPageProps & {
|
||||
/**
|
||||
* The provider to use, e.g. "gcp-iap" or "aws-alb". This must correspond to
|
||||
* a properly configured auth provider ID in the auth backend.
|
||||
*/
|
||||
provider: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A sign-in page that has no user interface of its own. Instead, it relies on
|
||||
* sign-in being performed by a reverse authenticating proxy that Backstage is
|
||||
* deployed behind, and leverages its session handling.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This sign-in page is useful when you are using products such as Google
|
||||
* Identity-Aware Proxy or AWS Application Load Balancer or similar, to front
|
||||
* your Backstage installation. This sign-in page implementation will silently
|
||||
* and regularly punch through the proxy to the auth backend to refresh your
|
||||
* frontend session information, without requiring user interaction.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ProxiedSignInPage = (props: ProxiedSignInPageProps) => {
|
||||
const discoveryApi = useApi(discoveryApiRef);
|
||||
|
||||
const { loading, error } = useAsync(async () => {
|
||||
const identity = new ProxiedSignInIdentity({
|
||||
provider: props.provider,
|
||||
discoveryApi,
|
||||
});
|
||||
|
||||
await identity.start();
|
||||
|
||||
props.onSignInSuccess(identity);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return (
|
||||
<ErrorPanel
|
||||
title="You do not appear to be signed in. Please try reloading the browser page."
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ProxiedSignInPage } from './ProxiedSignInPage';
|
||||
export type { ProxiedSignInPageProps } from './ProxiedSignInPage';
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 { TypeOf } from 'zod';
|
||||
import { ProxiedSession, proxiedSessionSchema } from './types';
|
||||
|
||||
describe('types', () => {
|
||||
const responseData: ProxiedSession = {
|
||||
providerInfo: {
|
||||
stuff: 1,
|
||||
},
|
||||
profile: {
|
||||
email: 'e',
|
||||
displayName: 'd',
|
||||
picture: 'p',
|
||||
},
|
||||
backstageIdentity: {
|
||||
id: 'i',
|
||||
token: 't',
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'ue',
|
||||
ownershipEntityRefs: ['oe'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('has a compatible schema type', () => {
|
||||
function f(_b: TypeOf<typeof proxiedSessionSchema>) {}
|
||||
f(responseData); // no tsc errors
|
||||
expect(proxiedSessionSchema.parse(responseData)).toEqual(responseData);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstageIdentityResponse,
|
||||
ProfileInfo,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const proxiedSessionSchema = z.object({
|
||||
providerInfo: z.object({}).catchall(z.unknown()).optional(),
|
||||
profile: z.object({
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
picture: z.string().optional(),
|
||||
}),
|
||||
backstageIdentity: z.object({
|
||||
id: z.string(),
|
||||
token: z.string(),
|
||||
identity: z.object({
|
||||
type: z.literal('user'),
|
||||
userEntityRef: z.string(),
|
||||
ownershipEntityRefs: z.array(z.string()),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generic session information for proxied sign-in providers, e.g. common
|
||||
* reverse authenticating proxy implementations.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ProxiedSession = {
|
||||
providerInfo?: { [key: string]: unknown };
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
};
|
||||
@@ -26,6 +26,7 @@ export * from './HomepageTimer';
|
||||
export * from './InfoCard';
|
||||
export * from './ItemCard';
|
||||
export * from './Page';
|
||||
export * from './ProxiedSignInPage';
|
||||
export * from './Sidebar';
|
||||
export * from './SignInPage';
|
||||
export * from './TabbedCard';
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
|
||||
Reference in New Issue
Block a user