auth: implement frontend parts of iap-like sign-in
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
committed by
Fredrik Adelöw
parent
6f5a1e74f5
commit
6415189d99
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Add a `DelegatedSignInPage` that can be used e.g. for GCP IAP and AWS ALB
|
||||
@@ -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>'
|
||||
```
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+2
-2
@@ -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';
|
||||
+5
-9
@@ -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);
|
||||
});
|
||||
});
|
||||
+6
-18
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user