add auth-backend-module-iap-provider

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-08-07 17:45:58 +02:00
parent c3aa1b91e1
commit 2f214950a3
14 changed files with 512 additions and 216 deletions
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# Auth Backend Module - GCP IAP Provider
## Links
- [The Backstage homepage](https://backstage.io)
@@ -0,0 +1,29 @@
/*
* Copyright 2020 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 interface Config {
auth?: {
providers?: {
gcpIap?: {
[authEnv: string]: {
audience: string;
jwtHeader?: string;
};
};
};
};
}
@@ -0,0 +1,52 @@
{
"name": "@backstage/plugin-auth-backend-module-gcp-iap-provider",
"description": "A GCP IAP auth provider module for the Backstage auth backend",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend-module-gcp-iap-provider"
},
"keywords": [
"backstage"
],
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/types": "workspace:^",
"google-auth-library": "^8.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"msw": "^1.0.0",
"supertest": "^6.1.3"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,52 @@
/*
* Copyright 2023 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 { createProxyAuthenticator } from '@backstage/plugin-auth-node';
import { createTokenValidator } from './helpers';
import { GcpIapResult } from './types';
/**
* The header name used by the IAP.
*/
const DEFAULT_IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion';
/** @public */
export const gcpIapAuthenticator = createProxyAuthenticator({
defaultProfileTransform: async (result: GcpIapResult) => {
return { profile: { email: result.iapToken.email } };
},
async initialize({ config }) {
const audience = config.getString('audience');
const jwtHeader =
config.getOptionalString('jwtHeader') ?? DEFAULT_IAP_JWT_HEADER;
const tokenValidator = createTokenValidator(audience);
return { jwtHeader, tokenValidator };
},
async authenticate({ req }, { jwtHeader, tokenValidator }) {
const token = req.header(jwtHeader);
if (!token || typeof token !== 'string') {
throw new AuthenticationError('Missing Google IAP header');
}
const iapToken = await tokenValidator(token);
return { result: { iapToken } };
},
});
@@ -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 { OAuth2Client } from 'google-auth-library';
import { createTokenValidator } from './helpers';
const mockJwt = 'a.b.c';
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(mockJwt)).resolves.toMatchObject({
sub: 's',
email: 'e@mail.com',
});
});
it('throws if listing keys fail', async () => {
const mockClient = {
getIapPublicKeys: async () => {
throw new Error('NOPE');
},
};
const validator = createTokenValidator(
'a',
mockClient as unknown as OAuth2Client,
);
await expect(validator(mockJwt)).rejects.toThrow(
'Unable to list Google IAP token verification keys, Error: NOPE',
);
});
it('throws if the verifying signature fails', async () => {
const mockClient = {
getIapPublicKeys: async () => ({ pubkeys: '' }),
verifySignedJwtWithCertsAsync: async () => {
throw new Error('NOPE');
},
};
const validator = createTokenValidator(
'a',
mockClient as unknown as OAuth2Client,
);
await expect(validator(mockJwt)).rejects.toThrow(
'Google IAP token verification failed, Error: NOPE',
);
});
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(mockJwt)).rejects.toThrow(
'Google IAP token verification failed, token had no payload',
);
});
it('rejects payload without subject', async () => {
const mockClient = {
getIapPublicKeys: async () => ({ pubkeys: '' }),
verifySignedJwtWithCertsAsync: async () => ({
getPayload: () => ({ email: 'e@mail.com' }),
}),
};
const validator = createTokenValidator(
'a',
mockClient as unknown as OAuth2Client,
);
await expect(validator(mockJwt)).rejects.toThrow(
'Google IAP token payload is missing subject claim',
);
});
it('rejects payload without email', async () => {
const mockClient = {
getIapPublicKeys: async () => ({ pubkeys: '' }),
verifySignedJwtWithCertsAsync: async () => ({
getPayload: () => ({ sub: 's' }),
}),
};
const validator = createTokenValidator(
'a',
mockClient as unknown as OAuth2Client,
);
await expect(validator(mockJwt)).rejects.toThrow(
'Google IAP token payload is missing email claim',
);
});
});
});
@@ -0,0 +1,67 @@
/*
* 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 } from 'google-auth-library';
import { GcpIapTokenInfo } from './types';
export function createTokenValidator(
audience: string,
providedClient?: OAuth2Client,
): (token: string) => Promise<GcpIapTokenInfo> {
const client = providedClient ?? 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().catch(error => {
throw new AuthenticationError(
`Unable to list Google IAP token verification keys, ${error}`,
);
});
const ticket = await client
.verifySignedJwtWithCertsAsync(token, response.pubkeys, audience, [
'https://cloud.google.com/iap',
])
.catch(error => {
throw new AuthenticationError(
`Google IAP token verification failed, ${error}`,
);
});
const payload = ticket.getPayload();
if (!payload) {
throw new AuthenticationError(
'Google IAP token verification failed, token had no payload',
);
}
if (!payload.sub) {
throw new AuthenticationError(
'Google IAP token payload is missing subject claim',
);
}
if (!payload.email) {
throw new AuthenticationError(
'Google IAP token payload is missing email claim',
);
}
return payload as unknown as GcpIapTokenInfo;
};
}
@@ -0,0 +1,19 @@
/*
* Copyright 2023 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 { gcpIapAuthenticator } from './authenticator';
export { authModuleGcpIapProvider } from './module';
export { gcpIapSignInResolvers } from './resolvers';
@@ -0,0 +1,48 @@
/*
* Copyright 2023 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 { createBackendModule } from '@backstage/backend-plugin-api';
import {
authProvidersExtensionPoint,
commonSignInResolvers,
createProxyAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import { gcpIapAuthenticator } from './authenticator';
import { gcpIapSignInResolvers } from './resolvers';
export const authModuleGcpIapProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'gcpIapProvider',
register(reg) {
reg.registerInit({
deps: {
providers: authProvidersExtensionPoint,
},
async init({ providers }) {
providers.registerProvider({
providerId: 'gcpIap',
factory: createProxyAuthProviderFactory({
authenticator: gcpIapAuthenticator,
signInResolverFactories: {
...gcpIapSignInResolvers,
...commonSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,49 @@
/*
* Copyright 2023 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 {
createSignInResolverFactory,
SignInInfo,
} from '@backstage/plugin-auth-node';
import { GcpIapResult } from './types';
/**
* Available sign-in resolvers for the Google auth provider.
*
* @public
*/
export namespace gcpIapSignInResolvers {
/**
* Looks up the user by matching their email to the `google.com/email` annotation.
*/
export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({
create() {
return async (info: SignInInfo<GcpIapResult>, ctx) => {
const email = info.result.iapToken.email;
if (!email) {
throw new Error('Google IAP sign-in result is missing email');
}
return ctx.signInWithCatalogUser({
annotations: {
'google.com/email': email,
},
});
};
},
});
}
@@ -0,0 +1,50 @@
/*
* Copyright 2023 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 { JsonPrimitive } from '@backstage/types';
/**
* 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]: JsonPrimitive;
};
/**
* 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;
};
@@ -1,134 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { 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.toThrow(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',
});
await expect(
parseRequestToken(undefined, undefined as any),
).rejects.toMatchObject({
name: 'AuthenticationError',
message: 'Missing Google IAP header',
});
await expect(
parseRequestToken('', undefined as any),
).rejects.toMatchObject({
name: 'AuthenticationError',
message: 'Missing Google IAP header',
});
});
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',
});
});
});
});
@@ -1,82 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AuthenticationError } from '@backstage/errors';
import { OAuth2Client, TokenPayload } from 'google-auth-library';
import { AuthHandler } from '../types';
import { GcpIapResult } 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');
}
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 } });
+16
View File
@@ -4576,6 +4576,22 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider"
dependencies:
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/types": "workspace:^"
google-auth-library: ^8.0.0
msw: ^1.0.0
supertest: ^6.1.3
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider"