use only a sign-in resolver

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-12-26 21:57:07 +01:00
parent 59c5c70bf7
commit 761ff980f1
21 changed files with 838 additions and 317 deletions
+77 -103
View File
@@ -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.
+37
View File
@@ -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';
+32 -6
View File
@@ -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;
+4 -5
View File
@@ -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
*/
+31 -18
View File
@@ -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 = (