Merge pull request #8589 from backstage/feature/add-gcp-iap-auth-provider
Updated: add gcp iap auth provider
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Added Google Cloud Identity-Aware Proxy as an identity provider.
|
||||
@@ -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",
|
||||
@@ -46,6 +47,7 @@
|
||||
"express-promise-router": "^4.1.0",
|
||||
"express-session": "^1.17.1",
|
||||
"fs-extra": "9.1.0",
|
||||
"google-auth-library": "^7.6.1",
|
||||
"helmet": "^4.0.0",
|
||||
"jose": "^1.27.1",
|
||||
"jwt-decode": "^3.1.0",
|
||||
@@ -83,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,134 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
|
||||
it('rejects empty payload', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => undefined,
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(validJwt)).rejects.toMatchObject({
|
||||
name: 'TypeError',
|
||||
message: 'Token had no payload',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 () => ({ sub: 'a' } as any)),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Google IAP token payload is missing sub and/or email claim',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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> {
|
||||
const client = mockClient ?? new OAuth2Client();
|
||||
|
||||
return async function tokenValidator(token) {
|
||||
// TODO(freben): Rate limit the public key reads. It may be sensible to
|
||||
// cache these for some reasonable time rather than asking for the public
|
||||
// keys on every single sign-in. But since the rate of events here is so
|
||||
// slow, I decided to keep it simple for now.
|
||||
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('Token had 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.sub || !payload.email) {
|
||||
throw new AuthenticationError(
|
||||
'Google IAP token payload is missing sub and/or email claim',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
iapToken: {
|
||||
...payload,
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultAuthHandler: AuthHandler<GcpIapResult> = async ({
|
||||
iapToken,
|
||||
}) => ({ profile: { email: iapToken.email } });
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { createGcpIapProvider } from './provider';
|
||||
export type {
|
||||
GcpIapProviderOptions,
|
||||
GcpIapResult,
|
||||
GcpIapTokenInfo,
|
||||
} from './types';
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2020 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 { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { GcpIapProvider } from './provider';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GcpIapProvider', () => {
|
||||
const authHandler = jest.fn();
|
||||
const signInResolver = jest.fn();
|
||||
const tokenValidator = jest.fn();
|
||||
const logger = getVoidLogger();
|
||||
|
||||
it('runs the happy path', async () => {
|
||||
const provider = new GcpIapProvider({
|
||||
authHandler,
|
||||
signInResolver,
|
||||
tokenValidator,
|
||||
tokenIssuer: {} as any,
|
||||
catalogIdentityClient: {} as any,
|
||||
logger,
|
||||
});
|
||||
|
||||
// { "sub": "user:default/me", "ent": ["group:default/home"] }
|
||||
const backstageToken =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvbWUiLCJlbnQiOlsiZ3JvdXA6ZGVmYXVsdC9ob21lIl19.CbmAKzFErGmtsnpRxyPc7dHv7WEjb5lY6206YCzR_Rc';
|
||||
const iapToken = { sub: 's', email: 'e@mail.com' };
|
||||
|
||||
authHandler.mockResolvedValueOnce({ email: 'e@mail.com' });
|
||||
signInResolver.mockResolvedValueOnce({ id: 'i', token: backstageToken });
|
||||
tokenValidator.mockResolvedValueOnce(iapToken);
|
||||
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
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,
|
||||
AuthProviderRouteHandlers,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import {
|
||||
createTokenValidator,
|
||||
defaultAuthHandler,
|
||||
parseRequestToken,
|
||||
} from './helpers';
|
||||
import {
|
||||
GcpIapProviderOptions,
|
||||
GcpIapResponse,
|
||||
GcpIapResult,
|
||||
IAP_JWT_HEADER,
|
||||
} from './types';
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async start() {}
|
||||
|
||||
async frameHandler() {}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
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),
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an auth provider for Google Identity-Aware Proxy.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createGcpIapProvider(
|
||||
options: GcpIapProviderOptions,
|
||||
): AuthProviderFactory {
|
||||
return ({ config, tokenIssuer, catalogApi, logger }) => {
|
||||
const audience = config.getString('audience');
|
||||
|
||||
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