use only a sign-in resolver
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: provider
|
||||
title: Google Identity Aware Proxy 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
|
||||
@@ -12,103 +12,76 @@ 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 ALB sitting in front of
|
||||
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.
|
||||
|
||||
## Infrastructure setup
|
||||
## Frontend Changes
|
||||
|
||||
## Backstage changes
|
||||
|
||||
### Frontend
|
||||
|
||||
The Backstage App needs a `SignInPage` to be configured. When using IAP Proxy
|
||||
The Backstage app needs a `SignInPage` to be configured. When using IAP Proxy
|
||||
authentication Backstage will only be loaded once the user has successfully
|
||||
authenticated; we won't need to display a sign-in page as such, however we will
|
||||
need to create a dummy `SignInPage` component that can decode and refresh the
|
||||
token.
|
||||
need to create a dummy `SignInPage` component that can fetch the token and other
|
||||
information from the backend.
|
||||
|
||||
- edit `packages/app/src/App.tsx`
|
||||
- import the following two additional definitions from `@backstage/core`:
|
||||
`useApi`, `configApiRef`; these will be used to check whether Backstage is
|
||||
running locally or behind an ALB
|
||||
- add the following definition just before the app is created
|
||||
(`const app = createApp`):
|
||||
Create a sign-in page component in your app, for example in
|
||||
`packages/app/src/components/SignInPage.tsx`.
|
||||
|
||||
```ts
|
||||
const refreshToken = async ({ props, discoveryApiConfig, config }) => {
|
||||
const baseUrl = await discoveryApiConfig.getBaseUrl('auth');
|
||||
const shouldAuth = !!config.getOptionalConfig('auth.providers.gcp-iap');
|
||||
```tsx
|
||||
import { GcpIapIdentity } from '@backstage/core-app-api';
|
||||
import { ErrorPanel, Progress } from '@backstage/core-components';
|
||||
import {
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
SignInPageProps,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
if (!shouldAuth) {
|
||||
props.onResult({
|
||||
userId: 'guest',
|
||||
profile: {
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
picture: '',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
export const GcpIapSignInPage = (props: SignInPageProps) => {
|
||||
const discovery = useApi(discoveryApiRef);
|
||||
const { fetch } = useApi(fetchApiRef);
|
||||
|
||||
try {
|
||||
const request = await fetch(`${baseUrl}/gcp-iap/refresh`, {
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
const { loading, error } = useAsync(async () => {
|
||||
const base = await discovery.getBaseUrl('auth');
|
||||
const response = await fetch(`${base}/gcp-iap/refresh`, {
|
||||
headers: { 'x-requested-with': 'XMLHttpRequest' },
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await request.json();
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
props.onSignInSuccess(GcpIapIdentity.fromResponse(await response.json()));
|
||||
}, []);
|
||||
|
||||
props.onResult({
|
||||
userId: data.backstageIdentity.id ?? 'nouser@ms.at',
|
||||
profile: data.profile ?? 'nouser@ms.at',
|
||||
});
|
||||
} catch (e) {
|
||||
props.onResult({
|
||||
userId: 'guest',
|
||||
profile: {
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
picture: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const DummySignInComponent: any = (props: any) => {
|
||||
try {
|
||||
const config = useApi(configApiRef);
|
||||
const discoveryApiConfig = useApi(discoveryApiRef);
|
||||
refreshToken({ props, discoveryApiConfig, config });
|
||||
return <div />;
|
||||
} catch (err) {
|
||||
return <div>{err.message}</div>;
|
||||
}
|
||||
if (loading) return <Progress />;
|
||||
if (error) return <ErrorPanel error={error} />;
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
### Backend
|
||||
Pass this sign in page to your `createApp` function, in
|
||||
`packages/app/src/App.tsx`.
|
||||
|
||||
When using ALB auth it is not possible to leverage the built-in auth config
|
||||
discovery mechanism implemented in the app created by default; bespoke logic
|
||||
needs to be implemented.
|
||||
```diff
|
||||
+import { GcpIapSignInPage } from './components/SignInPage';
|
||||
|
||||
- Replace the content of `packages/backend/plugin/auth.ts` with the below
|
||||
const app = createApp({
|
||||
components: {
|
||||
+ SignInPage: GcpIapSignInPage
|
||||
```
|
||||
|
||||
## Backend Changes
|
||||
|
||||
- Add a `providerFactories` entry to the router in
|
||||
`packages/backend/plugin/auth.ts`.
|
||||
|
||||
```ts
|
||||
// imports are relative - as this was tested out in repo directly
|
||||
import { createGcpIAPProvider } from './../providers/gcp-iap/provider';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
import {
|
||||
createRouter,
|
||||
AuthResponse,
|
||||
AuthProviderFactoryOptions,
|
||||
} from '@backstage/plugin-auth-backend';
|
||||
import { createGcpIapProvider } from '@backstage/plugin-auth-backend';
|
||||
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
@@ -116,48 +89,49 @@ export default async function createPlugin({
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
const identityResolver = (payload: any): Promise<AuthResponse<any>> => {
|
||||
return Promise.resolve({
|
||||
providerInfo: {},
|
||||
profile: {
|
||||
email: payload.email,
|
||||
displayName: payload.name,
|
||||
picture: payload.picture,
|
||||
},
|
||||
backstageIdentity: {
|
||||
id: payload.email,
|
||||
},
|
||||
});
|
||||
};
|
||||
return await createRouter({
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
discovery,
|
||||
providerFactories: {
|
||||
'gcp-iap': (options: AuthProviderFactoryOptions) => {
|
||||
return createGcpIAPProvider({ ...options, identityResolver })({
|
||||
...options,
|
||||
identityResolver,
|
||||
});
|
||||
},
|
||||
'gcp-iap': createGcpIapProvider({
|
||||
// Replace the auth handler if you want to customize the returned user
|
||||
// profile info (can be left out; default implementation 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
|
||||
## Configuration
|
||||
|
||||
Use the following `auth` configuration when running Backstage on AWS:
|
||||
Use the following `auth` configuration:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
providers:
|
||||
gcp-iap:
|
||||
audience: '/projects/0123456/global/backendServices/1242345678765434567'
|
||||
audience:
|
||||
'/projects/<project number>/global/backendServices/<backend service id>'
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Once it's deployed, after going through the AAD authentication flow, Backstage
|
||||
should display the AAD user details.
|
||||
|
||||
@@ -21,7 +21,9 @@ 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';
|
||||
@@ -388,6 +390,41 @@ 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)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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> {}
|
||||
}
|
||||
@@ -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 { GcpIapIdentity } from './GcpIapIdentity';
|
||||
export type { GcpIapSession } from './types';
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 { GcpIapSession, gcpIapSessionSchema } from './types';
|
||||
|
||||
describe('types', () => {
|
||||
const responseData: GcpIapSession = {
|
||||
providerInfo: {
|
||||
iapToken: {
|
||||
sub: 's',
|
||||
email: 'e',
|
||||
other: 7,
|
||||
},
|
||||
},
|
||||
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 gcpIapSessionSchema>) {}
|
||||
f(responseData); // no tsc errors
|
||||
expect(gcpIapSessionSchema.parse(responseData)).toEqual(responseData);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 gcpIapSessionSchema = z.object({
|
||||
providerInfo: z.object({
|
||||
iapToken: z
|
||||
.object({
|
||||
sub: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
.catchall(z.unknown()),
|
||||
}),
|
||||
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()),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Session information for Google Identity-Aware Proxy auth.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type GcpIapSession = {
|
||||
providerInfo: {
|
||||
iapToken: {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
};
|
||||
@@ -25,4 +25,5 @@ export * from './microsoft';
|
||||
export * from './onelogin';
|
||||
export * from './bitbucket';
|
||||
export * from './atlassian';
|
||||
export * from './gcp-iap';
|
||||
export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { JSONWebKey } from 'jose';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
@@ -59,8 +60,8 @@ export type Auth0ProviderOptions = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthHandler<AuthResult> = (
|
||||
input: AuthResult,
|
||||
export type AuthHandler<TAuthResult> = (
|
||||
input: TAuthResult,
|
||||
) => Promise<AuthHandlerResult>;
|
||||
|
||||
// @public
|
||||
@@ -249,6 +250,11 @@ export const createBitbucketProvider: (
|
||||
options?: BitbucketProviderOptions | undefined,
|
||||
) => AuthProviderFactory;
|
||||
|
||||
// @public
|
||||
export function createGcpIapProvider(
|
||||
options: GcpIapProviderOptions,
|
||||
): AuthProviderFactory;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createGithubProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -335,6 +341,26 @@ export const encodeState: (state: OAuthState) => string;
|
||||
// @public (undocumented)
|
||||
export const ensuresXRequestedWith: (req: express.Request) => boolean;
|
||||
|
||||
// @public
|
||||
export type GcpIapProviderOptions = {
|
||||
authHandler?: AuthHandler<GcpIapResult>;
|
||||
signIn: {
|
||||
resolver: SignInResolver<GcpIapResult>;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type GcpIapResult = {
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type GcpIapTokenInfo = {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "getEntityClaims" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
@@ -661,14 +687,14 @@ export type SamlProviderOptions = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SignInInfo<AuthResult> = {
|
||||
export type SignInInfo<TAuthResult> = {
|
||||
profile: ProfileInfo;
|
||||
result: AuthResult;
|
||||
result: TAuthResult;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SignInResolver<AuthResult> = (
|
||||
info: SignInInfo<AuthResult>,
|
||||
export type SignInResolver<TAuthResult> = (
|
||||
info: SignInInfo<TAuthResult>,
|
||||
context: {
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/errors": "^0.1.5",
|
||||
"@backstage/test-utils": "^0.2.1",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@google-cloud/firestore": "^4.15.1",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/passport": "^1.0.3",
|
||||
@@ -45,11 +46,8 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"express-session": "^1.17.1",
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"fs-extra": "9.1.0",
|
||||
>>>>>>> 4d5fd11228 (first minor cleanups)
|
||||
"google-auth-library": "^7.2.0",
|
||||
"google-auth-library": "^7.6.1",
|
||||
"helmet": "^4.0.0",
|
||||
"jose": "^1.27.1",
|
||||
"jwt-decode": "^3.1.0",
|
||||
@@ -87,7 +85,8 @@
|
||||
"@types/passport-saml": "^1.1.3",
|
||||
"@types/passport-strategy": "^0.2.35",
|
||||
"@types/xml2js": "^0.4.7",
|
||||
"msw": "^0.35.0"
|
||||
"msw": "^0.35.0",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createAwsAlbProvider } from './provider';
|
||||
export type { AwsAlbProviderOptions } from './provider';
|
||||
|
||||
@@ -18,7 +18,7 @@ import express from 'express';
|
||||
import { JWT } from 'jose';
|
||||
|
||||
import {
|
||||
ALB_ACCESSTOKEN_HEADER,
|
||||
ALB_ACCESS_TOKEN_HEADER,
|
||||
ALB_JWT_HEADER,
|
||||
AwsAlbAuthProvider,
|
||||
} from './provider';
|
||||
@@ -80,7 +80,7 @@ describe('AwsAlbAuthProvider', () => {
|
||||
header: jest.fn(name => {
|
||||
if (name === ALB_JWT_HEADER) {
|
||||
return mockJwt;
|
||||
} else if (name === ALB_ACCESSTOKEN_HEADER) {
|
||||
} else if (name === ALB_ACCESS_TOKEN_HEADER) {
|
||||
return mockAccessToken;
|
||||
}
|
||||
return undefined;
|
||||
@@ -88,7 +88,7 @@ describe('AwsAlbAuthProvider', () => {
|
||||
} as unknown as express.Request;
|
||||
const mockRequestWithoutJwt = {
|
||||
header: jest.fn(name => {
|
||||
if (name === ALB_ACCESSTOKEN_HEADER) {
|
||||
if (name === ALB_ACCESS_TOKEN_HEADER) {
|
||||
return mockAccessToken;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthHandler,
|
||||
AuthProviderFactory,
|
||||
@@ -35,7 +36,7 @@ import { AuthenticationError } from '@backstage/errors';
|
||||
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
|
||||
|
||||
export const ALB_JWT_HEADER = 'x-amzn-oidc-data';
|
||||
export const ALB_ACCESSTOKEN_HEADER = 'x-amzn-oidc-accesstoken';
|
||||
export const ALB_ACCESS_TOKEN_HEADER = 'x-amzn-oidc-accesstoken';
|
||||
|
||||
type Options = {
|
||||
region: string;
|
||||
@@ -134,7 +135,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
|
||||
|
||||
private async getResult(req: express.Request): Promise<AwsAlbResult> {
|
||||
const jwt = req.header(ALB_JWT_HEADER);
|
||||
const accessToken = req.header(ALB_ACCESSTOKEN_HEADER);
|
||||
const accessToken = req.header(ALB_ACCESS_TOKEN_HEADER);
|
||||
|
||||
if (jwt === undefined) {
|
||||
throw new AuthenticationError(
|
||||
@@ -144,7 +145,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
|
||||
|
||||
if (accessToken === undefined) {
|
||||
throw new AuthenticationError(
|
||||
`Missing ALB OIDC header: ${ALB_ACCESSTOKEN_HEADER}`,
|
||||
`Missing ALB OIDC header: ${ALB_ACCESS_TOKEN_HEADER}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 { ConflictError } from '@backstage/errors';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { createTokenValidator, parseRequestToken } from './helpers';
|
||||
|
||||
const validJwt =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('createTokenValidator', () => {
|
||||
it('runs the happy path', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => ({ sub: 's', email: 'e@mail.com' }),
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(validJwt)).resolves.toMatchObject({
|
||||
sub: 's',
|
||||
email: 'e@mail.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the client throws', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => {
|
||||
throw new TypeError('bam');
|
||||
},
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(validJwt)).rejects.toThrowError(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRequestToken', () => {
|
||||
it('runs the happy path', async () => {
|
||||
await expect(
|
||||
parseRequestToken(
|
||||
validJwt,
|
||||
async () => ({ sub: 's', email: 'e@mail.com' } as any),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
iapToken: {
|
||||
sub: 's',
|
||||
email: 'e@mail.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bad tokens', async () => {
|
||||
await expect(
|
||||
parseRequestToken(7, undefined as any),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing Google IAP header: x-goog-iap-jwt-assertion',
|
||||
});
|
||||
await expect(
|
||||
parseRequestToken(undefined, undefined as any),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing Google IAP header: x-goog-iap-jwt-assertion',
|
||||
});
|
||||
await expect(
|
||||
parseRequestToken('', undefined as any),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing Google IAP header: x-goog-iap-jwt-assertion',
|
||||
});
|
||||
});
|
||||
|
||||
it('translates validator errors', async () => {
|
||||
await expect(
|
||||
parseRequestToken(validJwt, async () => {
|
||||
throw new ConflictError('Ouch');
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Google IAP token verification failed, ConflictError: Ouch',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bad token payloads', async () => {
|
||||
await expect(
|
||||
parseRequestToken(validJwt, async () => undefined as any),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Google IAP token had no payload',
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseRequestToken(validJwt, async () => ({ sub: 'a' } as any)),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Google IAP token payload had no sub or email claim',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { AuthenticationError } from '@backstage/errors';
|
||||
import { OAuth2Client, TokenPayload } from 'google-auth-library';
|
||||
import { AuthHandler } from '../types';
|
||||
import { GcpIapResult, IAP_JWT_HEADER } from './types';
|
||||
|
||||
export function createTokenValidator(
|
||||
audience: string,
|
||||
mockClient?: OAuth2Client,
|
||||
): (token: string) => Promise<TokenPayload> {
|
||||
return async function tokenValidator(token) {
|
||||
const client = mockClient ?? new OAuth2Client();
|
||||
|
||||
const response = await client.getIapPublicKeys();
|
||||
const ticket = await client.verifySignedJwtWithCertsAsync(
|
||||
token,
|
||||
response.pubkeys,
|
||||
audience,
|
||||
['https://cloud.google.com/iap'],
|
||||
);
|
||||
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload) {
|
||||
throw new TypeError('No payload');
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseRequestToken(
|
||||
jwtToken: unknown,
|
||||
tokenValidator: (token: string) => Promise<TokenPayload>,
|
||||
): Promise<GcpIapResult> {
|
||||
if (typeof jwtToken !== 'string' || !jwtToken) {
|
||||
throw new AuthenticationError(
|
||||
`Missing Google IAP header: ${IAP_JWT_HEADER}`,
|
||||
);
|
||||
}
|
||||
|
||||
let payload: TokenPayload;
|
||||
try {
|
||||
payload = await tokenValidator(jwtToken);
|
||||
} catch (e) {
|
||||
throw new AuthenticationError(`Google IAP token verification failed, ${e}`);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new AuthenticationError('Google IAP token had no payload');
|
||||
} else if (!payload.sub || !payload.email) {
|
||||
throw new AuthenticationError(
|
||||
'Google IAP token payload had no sub or email claim',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
iapToken: {
|
||||
...payload,
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultAuthHandler: AuthHandler<GcpIapResult> = async ({
|
||||
iapToken,
|
||||
}) => ({ profile: { email: iapToken.email } });
|
||||
@@ -14,5 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createGcpIAPProvider } from './provider';
|
||||
export type { GcpIAPProviderOptions } from './provider';
|
||||
export { createGcpIapProvider } from './provider';
|
||||
export type {
|
||||
GcpIapProviderOptions,
|
||||
GcpIapResult,
|
||||
GcpIapTokenInfo,
|
||||
} from './types';
|
||||
|
||||
@@ -16,107 +16,61 @@
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import { AuthResponse } from '../types';
|
||||
import { GcpIAPProvider } from './provider';
|
||||
|
||||
jest.mock('google-auth-library');
|
||||
|
||||
const identityResolutionCallbackMock = async (): Promise<AuthResponse<any>> => {
|
||||
return {
|
||||
backstageIdentity: {
|
||||
id: 'foo',
|
||||
idToken: '',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'Foo Bar',
|
||||
},
|
||||
providerInfo: {},
|
||||
};
|
||||
};
|
||||
|
||||
const identityResolutionCallbackRejectedMock = async (): Promise<
|
||||
AuthResponse<any>
|
||||
> => {
|
||||
throw new Error('failed');
|
||||
};
|
||||
import request from 'supertest';
|
||||
import { GcpIapProvider } from './provider';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GcpIAPProvider', () => {
|
||||
const catalogApi = {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
addLocation: jest.fn(),
|
||||
removeLocationById: jest.fn(),
|
||||
getEntities: jest.fn(),
|
||||
getOriginLocationByEntity: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
};
|
||||
describe('GcpIapProvider', () => {
|
||||
const authHandler = jest.fn();
|
||||
const signInResolver = jest.fn();
|
||||
const tokenValidator = jest.fn();
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const mockRequest = {
|
||||
header: jest.fn(() => {
|
||||
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
|
||||
}),
|
||||
} as unknown as express.Request;
|
||||
const mockRequestWithoutJwt = {
|
||||
header: jest.fn(() => {
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as express.Request;
|
||||
const mockResponse = {
|
||||
end: jest.fn(),
|
||||
header: () => jest.fn(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
status: jest.fn(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
describe('should transform to type OAuthResponse', () => {
|
||||
it('when JWT is valid and identity is resolved successfully', async () => {
|
||||
const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, {
|
||||
identityResolutionCallback: identityResolutionCallbackMock,
|
||||
audience: 'foo',
|
||||
});
|
||||
|
||||
await provider.refresh(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
backstageIdentity: {
|
||||
id: 'foo',
|
||||
idToken: '',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'Foo Bar',
|
||||
},
|
||||
providerInfo: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('should fail when', () => {
|
||||
it('JWT is missing', async () => {
|
||||
const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, {
|
||||
identityResolutionCallback: identityResolutionCallbackMock,
|
||||
audience: 'foo',
|
||||
});
|
||||
|
||||
await provider.refresh(mockRequestWithoutJwt, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
it('runs the happy path', async () => {
|
||||
const provider = new GcpIapProvider({
|
||||
authHandler,
|
||||
signInResolver,
|
||||
tokenValidator,
|
||||
tokenIssuer: {} as any,
|
||||
catalogIdentityClient: {} as any,
|
||||
logger,
|
||||
});
|
||||
|
||||
it('identity resolution callback rejects', async () => {
|
||||
const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, {
|
||||
identityResolutionCallback: identityResolutionCallbackRejectedMock,
|
||||
audience: 'foo',
|
||||
});
|
||||
// { "sub": "user:default/me", "ent": ["group:default/home"] }
|
||||
const backstageToken =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvbWUiLCJlbnQiOlsiZ3JvdXA6ZGVmYXVsdC9ob21lIl19.CbmAKzFErGmtsnpRxyPc7dHv7WEjb5lY6206YCzR_Rc';
|
||||
const iapToken = { sub: 's', email: 'e@mail.com' };
|
||||
|
||||
await provider.refresh(mockRequest, mockResponse);
|
||||
authHandler.mockResolvedValueOnce({ email: 'e@mail.com' });
|
||||
signInResolver.mockResolvedValueOnce({ id: 'i', token: backstageToken });
|
||||
tokenValidator.mockResolvedValueOnce(iapToken);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.end).toHaveBeenCalledTimes(1);
|
||||
const app = express();
|
||||
app.use('/refresh', provider.refresh.bind(provider));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/refresh')
|
||||
.set('x-goog-iap-jwt-assertion', 'token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.get('content-type')).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
backstageIdentity: {
|
||||
id: 'i',
|
||||
idToken: backstageToken,
|
||||
token: backstageToken,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/me',
|
||||
ownershipEntityRefs: ['group:default/home'],
|
||||
},
|
||||
},
|
||||
providerInfo: { iapToken },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,111 +14,112 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import express from 'express';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { TokenPayload } from 'google-auth-library';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../../identity/types';
|
||||
import { CatalogIdentityClient } from '../../lib/catalog';
|
||||
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
|
||||
import {
|
||||
AuthHandler,
|
||||
AuthProviderFactory,
|
||||
AuthProviderFactoryOptions,
|
||||
AuthProviderRouteHandlers,
|
||||
ExperimentalIdentityResolver,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import {
|
||||
createTokenValidator,
|
||||
defaultAuthHandler,
|
||||
parseRequestToken,
|
||||
} from './helpers';
|
||||
import {
|
||||
GcpIapProviderOptions,
|
||||
GcpIapResponse,
|
||||
GcpIapResult,
|
||||
IAP_JWT_HEADER,
|
||||
} from './types';
|
||||
|
||||
const IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion';
|
||||
export class GcpIapProvider implements AuthProviderRouteHandlers {
|
||||
private readonly authHandler: AuthHandler<GcpIapResult>;
|
||||
private readonly signInResolver: SignInResolver<GcpIapResult>;
|
||||
private readonly tokenValidator: (token: string) => Promise<TokenPayload>;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
private readonly catalogIdentityClient: CatalogIdentityClient;
|
||||
private readonly logger: Logger;
|
||||
|
||||
export type GcpIAPProviderOptions = {
|
||||
audience: string;
|
||||
identityResolutionCallback: ExperimentalIdentityResolver;
|
||||
};
|
||||
export class GcpIAPProvider implements AuthProviderRouteHandlers {
|
||||
private logger: Logger;
|
||||
private options: GcpIAPProviderOptions;
|
||||
private readonly catalogClient: CatalogApi;
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
catalogClient: CatalogApi,
|
||||
options: GcpIAPProviderOptions,
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.catalogClient = catalogClient;
|
||||
this.options = options;
|
||||
constructor(options: {
|
||||
authHandler: AuthHandler<GcpIapResult>;
|
||||
signInResolver: SignInResolver<GcpIapResult>;
|
||||
tokenValidator: (token: string) => Promise<TokenPayload>;
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
logger: Logger;
|
||||
}) {
|
||||
this.authHandler = options.authHandler;
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.tokenValidator = options.tokenValidator;
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
this.catalogIdentityClient = options.catalogIdentityClient;
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
frameHandler(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
async start() {}
|
||||
|
||||
async frameHandler() {}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
const expectedAudience = this.options.audience;
|
||||
const jwtToken = req.header(IAP_JWT_HEADER);
|
||||
if (jwtToken === undefined) {
|
||||
res.status(401);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const oAuth2Client = new OAuth2Client();
|
||||
const verify = async () => {
|
||||
const response = await oAuth2Client.getIapPublicKeys();
|
||||
const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync(
|
||||
jwtToken,
|
||||
response.pubkeys,
|
||||
expectedAudience,
|
||||
['https://cloud.google.com/iap'],
|
||||
);
|
||||
return ticket.getPayload();
|
||||
const result = await parseRequestToken(
|
||||
req.header(IAP_JWT_HEADER),
|
||||
this.tokenValidator,
|
||||
);
|
||||
|
||||
const { profile } = await this.authHandler(result);
|
||||
|
||||
const backstageIdentity = await this.signInResolver(
|
||||
{ profile, result },
|
||||
{
|
||||
tokenIssuer: this.tokenIssuer,
|
||||
catalogIdentityClient: this.catalogIdentityClient,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
|
||||
const response: GcpIapResponse = {
|
||||
providerInfo: { iapToken: result.iapToken },
|
||||
profile,
|
||||
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
|
||||
};
|
||||
|
||||
try {
|
||||
const user = await verify();
|
||||
if (user === undefined) {
|
||||
this.logger.error('gcp iap proxy user returned undefined');
|
||||
res.status(401);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const resolvedEntity = await this.options.identityResolutionCallback(
|
||||
{
|
||||
email: user.email,
|
||||
},
|
||||
this.catalogClient,
|
||||
);
|
||||
res.json(resolvedEntity);
|
||||
} catch (e) {
|
||||
this.logger.error('Verification failed with', e);
|
||||
|
||||
res.status(401);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.status(200);
|
||||
res.end();
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
res.json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an auth provider for Google Identity-Aware Proxy.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createGcpIapProvider(
|
||||
_options?: GcpIAPProviderOptions,
|
||||
options: GcpIapProviderOptions,
|
||||
): AuthProviderFactory {
|
||||
return ({
|
||||
logger,
|
||||
catalogApi,
|
||||
config,
|
||||
identityResolver,
|
||||
}: AuthProviderFactoryOptions) => {
|
||||
return ({ config, tokenIssuer, catalogApi, logger }) => {
|
||||
const audience = config.getString('audience');
|
||||
if (identityResolver !== undefined) {
|
||||
throw new Error(
|
||||
'Identity resolver is required to use this authentication provider',
|
||||
);
|
||||
}
|
||||
return new GcpIAPProvider(logger, catalogApi, {
|
||||
audience,
|
||||
identityResolutionCallback: identityResolver,
|
||||
|
||||
const authHandler = options.authHandler ?? defaultAuthHandler;
|
||||
const signInResolver = options.signIn.resolver;
|
||||
const tokenValidator = createTokenValidator(audience);
|
||||
|
||||
const catalogIdentityClient = new CatalogIdentityClient({
|
||||
catalogApi,
|
||||
tokenIssuer,
|
||||
});
|
||||
|
||||
return new GcpIapProvider({
|
||||
authHandler,
|
||||
signInResolver,
|
||||
tokenValidator,
|
||||
tokenIssuer,
|
||||
catalogIdentityClient,
|
||||
logger,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { JsonValue } from '@backstage/types';
|
||||
import { AuthHandler, AuthResponse, SignInResolver } from '../types';
|
||||
|
||||
/**
|
||||
* The header name used by the IAP.
|
||||
*/
|
||||
export const IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion';
|
||||
|
||||
/**
|
||||
* The data extracted from an IAP token.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type GcpIapTokenInfo = {
|
||||
/**
|
||||
* The unique, stable identifier for the user.
|
||||
*/
|
||||
sub: string;
|
||||
/**
|
||||
* User email address.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* Other fields.
|
||||
*/
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result of the initial auth challenge. This is the input to the auth
|
||||
* callbacks.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type GcpIapResult = {
|
||||
/**
|
||||
* The data extracted from the IAP token header.
|
||||
*/
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* The provider info to return to the frontend.
|
||||
*/
|
||||
export type GcpIapProviderInfo = {
|
||||
/**
|
||||
* The data extracted from the IAP token header.
|
||||
*/
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* The shape of the response to return to callers.
|
||||
*/
|
||||
export type GcpIapResponse = AuthResponse<GcpIapProviderInfo>;
|
||||
|
||||
/**
|
||||
* Options for {@link createGcpIapProvider}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type GcpIapProviderOptions = {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth
|
||||
* response into the profile that will be presented to the user. The default
|
||||
* implementation just provides the authenticated email that the IAP
|
||||
* presented.
|
||||
*/
|
||||
authHandler?: AuthHandler<GcpIapResult>;
|
||||
|
||||
/**
|
||||
* Configures sign-in for this provider.
|
||||
*/
|
||||
signIn: {
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*/
|
||||
resolver: SignInResolver<GcpIapResult>;
|
||||
};
|
||||
};
|
||||
@@ -27,6 +27,7 @@ export * from './oidc';
|
||||
export * from './okta';
|
||||
export * from './onelogin';
|
||||
export * from './saml';
|
||||
export * from './gcp-iap';
|
||||
|
||||
export { factories as defaultAuthProviderFactories } from './factories';
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ function parseJwtPayload(token: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses token and decorates the BackstageIdentityResponse with identity information sourced from the token
|
||||
* Parses a Backstage-issued token and decorates the
|
||||
* {@link BackstageIdentityResponse} with identity information sourced from the
|
||||
* token.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -200,13 +200,16 @@ export interface BackstageSignInResult {
|
||||
|
||||
/**
|
||||
* The old exported symbol for {@link BackstageSignInResult}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use the `BackstageSignInResult` type instead.
|
||||
* @deprecated Use the {@link BackstageSignInResult} instead.
|
||||
*/
|
||||
export type BackstageIdentity = BackstageSignInResult;
|
||||
|
||||
/**
|
||||
* Response object containing the {@link BackstageUserIdentity} and the token from the authentication provider.
|
||||
* Response object containing the {@link BackstageUserIdentity} and the token
|
||||
* from the authentication provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BackstageIdentityResponse extends BackstageSignInResult {
|
||||
@@ -220,7 +223,8 @@ export interface BackstageIdentityResponse extends BackstageSignInResult {
|
||||
* Used to display login information to user, i.e. sidebar popup.
|
||||
*
|
||||
* 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
|
||||
* identity, but we want to replace that with data from identity and/org catalog
|
||||
* service
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
@@ -241,28 +245,32 @@ export type ProfileInfo = {
|
||||
};
|
||||
|
||||
/**
|
||||
* type of sign in information context, includes the profile information and authentication result which contains auth. related information
|
||||
* Type of sign in information context. Includes the profile information and
|
||||
* authentication result which contains auth related information.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SignInInfo<AuthResult> = {
|
||||
export type SignInInfo<TAuthResult> = {
|
||||
/**
|
||||
* The simple profile passed down for use in the frontend.
|
||||
*/
|
||||
profile: ProfileInfo;
|
||||
|
||||
/**
|
||||
* The authentication result that was received from the authentication provider.
|
||||
* The authentication result that was received from the authentication
|
||||
* provider.
|
||||
*/
|
||||
result: AuthResult;
|
||||
result: TAuthResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign in resolver type describes the function which handles the result of a successful authentication
|
||||
* and it must return a valid {@link BackstageSignInResult}
|
||||
* Describes the function which handles the result of a successful
|
||||
* authentication. Must return a valid {@link BackstageSignInResult}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SignInResolver<AuthResult> = (
|
||||
info: SignInInfo<AuthResult>,
|
||||
export type SignInResolver<TAuthResult> = (
|
||||
info: SignInInfo<TAuthResult>,
|
||||
context: {
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
@@ -271,23 +279,28 @@ export type SignInResolver<AuthResult> = (
|
||||
) => Promise<BackstageSignInResult>;
|
||||
|
||||
/**
|
||||
* The return type of authentication handler which must contain a valid profile information
|
||||
* The return type of an authentication handler. Must contain valid profile
|
||||
* information.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AuthHandlerResult = { profile: ProfileInfo };
|
||||
|
||||
/**
|
||||
* The AuthHandler function is called every time the user authenticates using the provider.
|
||||
* The AuthHandler function is called every time the user authenticates using
|
||||
* the provider.
|
||||
*
|
||||
* The handler should return a profile that represents the session for the user in the frontend.
|
||||
* The handler should return a profile that represents the session for the user
|
||||
* in the frontend.
|
||||
*
|
||||
* Throwing an error in the function will cause the authentication to fail, making it
|
||||
* possible to use this function as a way to limit access to a certain group of users.
|
||||
* Throwing an error in the function will cause the authentication to fail,
|
||||
* making it possible to use this function as a way to limit access to a certain
|
||||
* group of users.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AuthHandler<AuthResult> = (
|
||||
input: AuthResult,
|
||||
export type AuthHandler<TAuthResult> = (
|
||||
input: TAuthResult,
|
||||
) => Promise<AuthHandlerResult>;
|
||||
|
||||
export type StateEncoder = (
|
||||
|
||||
Reference in New Issue
Block a user