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:
Fredrik Adelöw
2022-01-13 12:17:18 +01:00
committed by GitHub
12 changed files with 697 additions and 2 deletions
+10
View File
@@ -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
+5 -2
View File
@@ -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';