auth: implement frontend parts of iap-like sign-in

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-12-28 17:30:00 +01:00
committed by Fredrik Adelöw
parent 6f5a1e74f5
commit 6415189d99
11 changed files with 357 additions and 142 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Add a `DelegatedSignInPage` that can be used e.g. for GCP IAP and AWS ALB
+98
View File
@@ -0,0 +1,98 @@
---
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.
## Frontend Changes
Any Backstage app needs a `SignInPage` to be configured. When using IAP Proxy
authentication Backstage will only be loaded once the user has already
successfully authenticated. As such, we won't need to display a sign-in page
visually, however we will need to pick a `SignInPage` implementation which knows
how to silently fetch the IAP-injected token and other information via the
backend.
Update your `createApp` call in `packages/app/src/App.tsx`, to pass in the
proper sign-in page implementation.
```diff
+import { DelegatedSignInPage } from '@backstage/core-components';
const app = createApp({
components: {
+ SignInPage: props => <DelegatedSignInPage {...props} provider="gcp-iap" />,
```
## Backend Changes
- 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.
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 };
},
},
}),
},
});
}
```
## Configuration
Use the following `auth` configuration:
```yaml
auth:
providers:
gcp-iap:
audience:
'/projects/<project number>/global/backendServices/<backend service id>'
```
-37
View File
@@ -21,9 +21,7 @@ import { AuthProviderInfo } from '@backstage/core-plugin-api';
import { AuthRequestOptions } from '@backstage/core-plugin-api';
import { BackstageIdentity } from '@backstage/core-plugin-api';
import { BackstageIdentityApi } from '@backstage/core-plugin-api';
import { BackstageIdentityResponse } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
import { bitbucketAuthApiRef } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { Config } from '@backstage/config';
@@ -390,41 +388,6 @@ export type FlatRoutesProps = {
children: ReactNode;
};
// @public
export class GcpIapIdentity implements IdentityApi {
constructor(session: GcpIapSession);
static fromResponse(data: unknown): GcpIapIdentity;
// (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>;
}
// @public
export type GcpIapSession = {
providerInfo: {
iapToken: {
sub: string;
email: string;
[key: string]: unknown;
};
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentityResponse;
};
// @public
export class GithubAuth implements OAuthApi, SessionApi {
// (undocumented)
@@ -1,74 +0,0 @@
/*
* 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';
import { GcpIapSession, gcpIapSessionSchema } from './types';
/**
* An identity API based on a Google Identity-Aware Proxy auth response.
*
* @public
*/
export class GcpIapIdentity implements IdentityApi {
/**
* Creates an identity instance based on the auth-backend API response.
*
* @param data - The raw JSON response from the auth-backend.
*/
static fromResponse(data: unknown): GcpIapIdentity {
const parsed = gcpIapSessionSchema.parse(data);
return new GcpIapIdentity(parsed);
}
constructor(private readonly session: GcpIapSession) {}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */
getUserId(): string {
return this.session.backstageIdentity.id;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */
async getIdToken(): Promise<string | undefined> {
return this.session.backstageIdentity.token;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */
getProfile(): ProfileInfo {
return this.session.profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */
async getProfileInfo(): Promise<ProfileInfo> {
return this.session.profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
return this.session.backstageIdentity.identity;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */
async getCredentials(): Promise<{ token?: string | undefined }> {
return { token: this.session.backstageIdentity.token };
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */
async signOut(): Promise<void> {}
}
@@ -25,5 +25,4 @@ export * from './microsoft';
export * from './onelogin';
export * from './bitbucket';
export * from './atlassian';
export * from './gcp-iap';
export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types';
+2 -1
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",
@@ -0,0 +1,149 @@
/*
* 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,
errorApiRef,
fetchApiRef,
IdentityApi,
ProfileInfo,
} from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { DelegatedSession, delegatedSessionSchema } from './types';
type DelegatedSignInIdentityOptions = {
provider: string;
refreshFrequencyMillis: number;
retryRefreshFrequencyMillis: number;
discoveryApi: typeof discoveryApiRef.T;
fetchApi: typeof fetchApiRef.T;
errorApi: typeof errorApiRef.T;
};
/**
* An identity API that keeps itself up to date solely based on getting session
* information from a `/refresh` endpoint.
*/
export class DelegatedSignInIdentity implements IdentityApi {
private readonly options: DelegatedSignInIdentityOptions;
private readonly abortController: AbortController;
private session: DelegatedSession | undefined;
constructor(options: DelegatedSignInIdentityOptions) {
this.options = options;
this.abortController = new AbortController();
this.session = undefined;
}
async start() {
await this.refresh(false);
let signalErrors = true;
const refreshLoop = async () => {
if (!this.abortController.signal.aborted) {
try {
await this.refresh(signalErrors);
signalErrors = true;
setTimeout(refreshLoop, this.options.refreshFrequencyMillis);
} catch {
signalErrors = false;
setTimeout(refreshLoop, this.options.retryRefreshFrequencyMillis);
}
}
};
setTimeout(refreshLoop, this.options.refreshFrequencyMillis);
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */
getUserId(): string {
return this.getSession().backstageIdentity.id;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */
async getIdToken(): Promise<string | undefined> {
return this.getSession().backstageIdentity.token;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */
getProfile(): ProfileInfo {
return this.getSession().profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */
async getProfileInfo(): Promise<ProfileInfo> {
return this.getSession().profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
return this.getSession().backstageIdentity.identity;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */
async getCredentials(): Promise<{ token?: string | undefined }> {
return { token: this.getSession().backstageIdentity.token };
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */
async signOut(): Promise<void> {
this.abortController.abort();
}
getSession(): DelegatedSession {
if (!this.session) {
throw new Error('No session available');
}
return this.session;
}
async refresh(notifyErrors: boolean) {
try {
const baseUrl = await this.options.discoveryApi.getBaseUrl('auth');
const response = await this.options.fetchApi.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);
}
const session = delegatedSessionSchema.parse(await response.json());
this.session = session;
} catch (e) {
if (this.abortController.signal.aborted) {
return;
}
if (notifyErrors) {
this.options.errorApi.post(
new Error(
`Failed to refresh browser session, ${e}. Try reloading your browser page.`,
),
);
}
throw e;
}
}
}
@@ -0,0 +1,90 @@
/*
* 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,
errorApiRef,
fetchApiRef,
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 { DelegatedSignInIdentity } from './DelegatedSignInIdentity';
/**
* Props for {@link DelegatedSignInPage}.
*
* @public
*/
export type DelegatedSignInPageProps = 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 delegates
* sign-in to some 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 DelegatedSignInPage = (props: DelegatedSignInPageProps) => {
const discoveryApi = useApi(discoveryApiRef);
const fetchApi = useApi(fetchApiRef);
const errorApi = useApi(errorApiRef);
const { loading, error } = useAsync(async () => {
const identity = new DelegatedSignInIdentity({
provider: props.provider,
refreshFrequencyMillis: 60_000,
retryRefreshFrequencyMillis: 10_000,
errorApi,
discoveryApi,
fetchApi,
});
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;
};
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { GcpIapIdentity } from './GcpIapIdentity';
export type { GcpIapSession } from './types';
export { DelegatedSignInPage } from './DelegatedSignInPage';
export type { DelegatedSignInPageProps } from './DelegatedSignInPage';
@@ -15,16 +15,12 @@
*/
import { TypeOf } from 'zod';
import { GcpIapSession, gcpIapSessionSchema } from './types';
import { DelegatedSession, delegatedSessionSchema } from './types';
describe('types', () => {
const responseData: GcpIapSession = {
const responseData: DelegatedSession = {
providerInfo: {
iapToken: {
sub: 's',
email: 'e',
other: 7,
},
stuff: 1,
},
profile: {
email: 'e',
@@ -43,8 +39,8 @@ describe('types', () => {
};
it('has a compatible schema type', () => {
function f(_b: TypeOf<typeof gcpIapSessionSchema>) {}
function f(_b: TypeOf<typeof delegatedSessionSchema>) {}
f(responseData); // no tsc errors
expect(gcpIapSessionSchema.parse(responseData)).toEqual(responseData);
expect(delegatedSessionSchema.parse(responseData)).toEqual(responseData);
});
});
@@ -20,15 +20,8 @@ import {
} from '@backstage/core-plugin-api';
import { z } from 'zod';
export const gcpIapSessionSchema = z.object({
providerInfo: z.object({
iapToken: z
.object({
sub: z.string(),
email: z.string(),
})
.catchall(z.unknown()),
}),
export const delegatedSessionSchema = z.object({
providerInfo: z.object({}).catchall(z.unknown()).optional(),
profile: z.object({
email: z.string().optional(),
displayName: z.string().optional(),
@@ -46,18 +39,13 @@ export const gcpIapSessionSchema = z.object({
});
/**
* Session information for Google Identity-Aware Proxy auth.
* Generic session information for delegated sign-in providers, e.g. common
* reverse authenticating proxy implementations.
*
* @public
*/
export type GcpIapSession = {
providerInfo: {
iapToken: {
sub: string;
email: string;
[key: string]: unknown;
};
};
export type DelegatedSession = {
providerInfo?: { [key: string]: unknown };
profile: ProfileInfo;
backstageIdentity: BackstageIdentityResponse;
};