From f79df8ab24cfcd96ada154bac1a29642aae9109e Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Wed, 25 Jan 2023 09:39:46 +0000 Subject: [PATCH] Initial easyauth work Signed-off-by: Alex Crome --- docs/auth/microsoft/azure-easyauth.md | 120 +++++++ mkdocs.yml | 1 + .../src/providers/azure-easyauth/index.ts | 18 ++ .../providers/azure-easyauth/provider.test.ts | 292 ++++++++++++++++++ .../src/providers/azure-easyauth/provider.ts | 202 ++++++++++++ 5 files changed, 633 insertions(+) create mode 100644 docs/auth/microsoft/azure-easyauth.md create mode 100644 plugins/auth-backend/src/providers/azure-easyauth/index.ts create mode 100644 plugins/auth-backend/src/providers/azure-easyauth/provider.test.ts create mode 100644 plugins/auth-backend/src/providers/azure-easyauth/provider.ts diff --git a/docs/auth/microsoft/azure-easyauth.md b/docs/auth/microsoft/azure-easyauth.md new file mode 100644 index 0000000000..00d798dce9 --- /dev/null +++ b/docs/auth/microsoft/azure-easyauth.md @@ -0,0 +1,120 @@ +--- +id: azure-easy-auth +title: Azure EasyAuth Provider +sidebar_label: Azure EasyAuth +description: Adding Azure's EasyAuth Proxy as an authentication provider in Backstage +--- + +## Backstage Changes + +Add the following into your `app-config.yaml` or `app-config.production.yaml` file + +Add a `providerFactories` entry to the router in +`packages/backend/src/plugins/auth.ts`. + +```ts +import { providers } from '@backstage/plugin-auth-backend'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const authProviderFactories = { + 'azure-easyAuth': providers.easyAuth.create({ + signIn: { + resolver: async (info, ctx) => { + const { + fullProfile: { id }, + } = info.result; + + if (!id) { + throw new Error('User profile contained no id'); + } + + return await ctx.signInWithCatalogUser({ + annotations: { + 'graph.microsoft.com/user-id': id, + }, + }); + }, + }, + }), + }; + + return await createRouter({ + logger: env.logger, + config: env.config, + database: env.database, + discovery: env.discovery, + tokenManager: env.tokenManager, + providerFactories: authProviderFactories, + }); +} +``` + +Now the backend is ready to serve auth requests on the +`/api/auth/azure-easyAuth/refresh` endpoint. All that's left is to update the frontend +sign-in mechanism to poll that endpoint through the IAP, on the user's behalf. + +## Frontend Changes + +It is recommended to use the `ProxiedSignInPage` for this provider, which is +installed in `packages/app/src/App.tsx` like this: + +```diff ++import { ProxiedSignInPage } from '@backstage/core-components'; + + const app = createApp({ + components: { ++ SignInPage: props => , +``` + +See the [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) section for more information. + +## Azure Configuration + +How to configure azure depends on the service you're enable AAD auth on the app service. + +### Azure App Services + +To use EasyAuth with App Services, turn on Active Directory authentication +You must also enable the token store. + +The following example shows how to do this via a bicep template: + +```bicep +resource webApp 'Microsoft.Web/sites@2022-03-01' existing = { + name: 'MY-WEBAPP-NAME' + + resource authConfig 'config' = { + name: 'authsettingsV2' + properties: { + globalValidation: { + redirectToProvider: 'AzureActiveDirectory' + requireAuthentication: true + unauthenticatedClientAction: 'RedirectToLoginPage' + } + login: { + tokenStore: { + enabled: true + } + } + platform: { + enabled: true + } + identityProviders: { + azureActiveDirectory: { + enabled: true + login: { + loginParameters: [ 'domain_hint=MYCOMPANY.COM' ] + } + registration: { + clientId: 'CLIENT-ID' + clientSecretSettingName: 'CLIENT-SECRET-NAME' + openIdIssuer: 'https://sts.windows.net/${tenant().tenantId}/v2.0' + } + } + } + } + } +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 38369f4748..4b5c2cf7a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -154,6 +154,7 @@ nav: - Included providers: - Auth0: 'auth/auth0/provider.md' - Azure: 'auth/microsoft/provider.md' + - Azure EasyAuth: 'auth/microsoft/azure-easyauth.md' - GitHub: 'auth/github/provider.md' - GitLab: 'auth/gitlab/provider.md' - Google: 'auth/google/provider.md' diff --git a/plugins/auth-backend/src/providers/azure-easyauth/index.ts b/plugins/auth-backend/src/providers/azure-easyauth/index.ts new file mode 100644 index 0000000000..698092d73f --- /dev/null +++ b/plugins/auth-backend/src/providers/azure-easyauth/index.ts @@ -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 { easyAuth } from './provider'; +export type { EasyAuthResponse } from './provider'; diff --git a/plugins/auth-backend/src/providers/azure-easyauth/provider.test.ts b/plugins/auth-backend/src/providers/azure-easyauth/provider.test.ts new file mode 100644 index 0000000000..f9f0796f7a --- /dev/null +++ b/plugins/auth-backend/src/providers/azure-easyauth/provider.test.ts @@ -0,0 +1,292 @@ +/* + * 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. + */ + +import { AuthHandler, AuthResolverContext } from '../types'; +import { makeProfileInfo } from '../../lib/passport'; +import { + easyAuth, + ACCESS_TOKEN_HEADER, + EasyAuthAuthProvider, + EasyAuthResult, + ID_TOKEN_HEADER, +} from './provider'; +import { Request, Response } from 'express'; +import { SignJWT, JWTPayload, errors as JoseErrors } from 'jose'; +import { randomBytes } from 'crypto'; + +const jwtSecret = randomBytes(48); + +async function buildJwt(claims: JWTPayload) { + return await new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256' }) + .sign(jwtSecret); +} + +const backstageIdentityTokenClaims = { + sub: 'user:default/alice', + ent: ['user:default/alice'], +}; + +describe('EasyAuthAuthProvider', () => { + const authHandler: AuthHandler = async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }); + const resolverContext: AuthResolverContext = {} as AuthResolverContext; + async function signInResolver() { + return { + id: 'user.name', + token: await buildJwt(backstageIdentityTokenClaims), + }; + } + + const provider = new EasyAuthAuthProvider({ + authHandler, + signInResolver, + resolverContext, + }); + + function mockRequest(headers?: Record) { + return { + header: (name: string) => headers?.[name], + } as unknown as Request; + } + + describe('should succeed when', () => { + it('id_token is valid and identity is resolved successfully', async () => { + const claims = { + ver: '2.0', + oid: 'c43063d4-0650-4f3e-ba6b-307473d24dfd', + name: 'Alice Bob', + email: 'alice@bob.com', + preferred_username: 'Another name', + }; + const response = { + end: jest.fn(), + header: () => jest.fn(), + json: jest.fn(), + status: jest.fn(), + } as unknown as Response; + + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt(claims), + }); + await provider.refresh(request, response); + + expect(response.json).toHaveBeenCalledWith({ + backstageIdentity: { + id: 'user.name', + token: await buildJwt(backstageIdentityTokenClaims), + identity: { + ownershipEntityRefs: ['user:default/alice'], + type: 'user', + userEntityRef: 'user:default/alice', + }, + }, + profile: { + displayName: claims.name, + email: claims.email, + picture: undefined, + }, + providerInfo: { + accessToken: undefined, + }, + }); + }); + + it('valid id_token and access_token provided', async () => { + const claims = { + ver: '2.0', + oid: 'c43063d4-0650-4f3e-ba6b-307473d24dfd', + name: 'Alice Bob', + email: 'alice@bob.com', + preferred_username: 'Another name', + }; + const response = { + end: jest.fn(), + header: () => jest.fn(), + json: jest.fn(), + status: jest.fn(), + } as unknown as Response; + + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt(claims), + [ACCESS_TOKEN_HEADER]: 'ACCESS_TOKEN', + }); + await provider.refresh(request, response); + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + providerInfo: { + accessToken: 'ACCESS_TOKEN', + }, + }), + ); + }); + }); + + describe('should fail when', () => { + const response = {} as Response; + + it('Access token is missing', async () => { + const request = mockRequest(); + + await expect(provider.refresh(request, response)).rejects.toThrow( + 'Missing x-ms-token-aad-id-token header', + ); + }); + + it('id token is invalid', async () => { + const request = mockRequest({ + [ID_TOKEN_HEADER]: 'not-a-jwt', + }); + + await expect(provider.refresh(request, response)).rejects.toThrow( + JoseErrors.JWTInvalid, + ); + }); + + it('id token is v1', async () => { + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt({ ver: '1.0' }), + }); + + await expect(provider.refresh(request, response)).rejects.toThrow( + 'id_token is not version 2.0', + ); + }); + + it('SignInResolver rejects', async () => { + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt({ ver: '2.0' }), + }); + + const rejectProvider = new EasyAuthAuthProvider({ + authHandler, + signInResolver: async () => { + throw new Error('REJECTED!!'); + }, + resolverContext, + }); + + await expect(rejectProvider.refresh(request, response)).rejects.toThrow( + 'REJECTED!!', + ); + }); + + it('AuthHanlder rejects', async () => { + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt({ ver: '2.0' }), + }); + + const rejectProvider = new EasyAuthAuthProvider({ + authHandler: async () => { + throw new Error('OBJECTION!!'); + }, + signInResolver, + resolverContext, + }); + + await expect(rejectProvider.refresh(request, response)).rejects.toThrow( + 'OBJECTION!!', + ); + }); + }); +}); + +describe('easyAuth factory', () => { + const env = process.env; + beforeEach(() => { + jest.resetModules(); + process.env = { ...env }; + }); + + afterEach(() => { + process.env = env; + }); + + it('should fail when run outside of Azure App Services', async () => { + const factory = easyAuth.create({ + signIn: { + resolver: jest.fn(), + }, + }); + + expect(() => factory({} as any)).toThrow( + 'Backstage is not running on Azure App Services', + ); + }); + + it('should fail when Azure App Services Auth is not enabled', async () => { + process.env.WEBSITE_SKU = 'Standard'; + process.env.WEBSITE_AUTH_ENABLED = 'False'; + + const factory = easyAuth.create({ + signIn: { + resolver: jest.fn(), + }, + }); + + expect(() => factory({} as any)).toThrow( + 'Azure App Services does not have authentication enabled', + ); + }); + + it('should fail when Azure App Services Auth is not AAD', async () => { + process.env.WEBSITE_SKU = 'Standard'; + process.env.WEBSITE_AUTH_ENABLED = 'True'; + process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'Facebook'; + + const factory = easyAuth.create({ + signIn: { + resolver: jest.fn(), + }, + }); + + expect(() => factory({} as any)).toThrow( + 'Authentication provider is not Azure Active Directory', + ); + }); + + it('should fail when Token Store not enabled', async () => { + process.env.WEBSITE_SKU = 'Standard'; + process.env.WEBSITE_AUTH_ENABLED = 'True'; + process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'AzureActiveDirectory'; + process.env.WEBSITE_AUTH_TOKEN_STORE = 'False'; + + const factory = easyAuth.create({ + signIn: { + resolver: jest.fn(), + }, + }); + + expect(() => factory({} as any)).toThrow('Token Store is not enabled'); + }); + + it('should return EasyAuthAuthProvider when running in Azure App Services with AAD Auth', async () => { + process.env.WEBSITE_SKU = 'Standard'; + process.env.WEBSITE_AUTH_ENABLED = 'True'; + process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'AzureActiveDirectory'; + process.env.WEBSITE_AUTH_TOKEN_STORE = 'True'; + + const factory = easyAuth.create({ + signIn: { + resolver: jest.fn(), + }, + }); + + expect(factory({} as any)).toBeInstanceOf(EasyAuthAuthProvider); + }); +}); diff --git a/plugins/auth-backend/src/providers/azure-easyauth/provider.ts b/plugins/auth-backend/src/providers/azure-easyauth/provider.ts new file mode 100644 index 0000000000..8debc3afb6 --- /dev/null +++ b/plugins/auth-backend/src/providers/azure-easyauth/provider.ts @@ -0,0 +1,202 @@ +/* + * 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. + */ + +import { + AuthHandler, + AuthProviderRouteHandlers, + AuthResolverContext, + AuthResponse, + SignInResolver, +} from '../types'; +import { Request, Response } from 'express'; +import { makeProfileInfo } from '../../lib/passport'; +import { AuthenticationError } from '@backstage/errors'; +import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { Profile } from 'passport'; +import { decodeJwt } from 'jose'; + +export const ID_TOKEN_HEADER = 'x-ms-token-aad-id-token'; +export const ACCESS_TOKEN_HEADER = 'x-ms-token-aad-access-token'; + +type Options = { + authHandler: AuthHandler; + signInResolver: SignInResolver; + resolverContext: AuthResolverContext; +}; + +export type EasyAuthResult = { + fullProfile: Profile; + accessToken?: string; +}; + +export type EasyAuthResponse = AuthResponse<{}>; + +export class EasyAuthAuthProvider implements AuthProviderRouteHandlers { + private readonly resolverContext: AuthResolverContext; + private readonly authHandler: AuthHandler; + private readonly signInResolver: SignInResolver; + + constructor(options: Options) { + this.authHandler = options.authHandler; + this.signInResolver = options.signInResolver; + this.resolverContext = options.resolverContext; + } + + frameHandler(): Promise { + return Promise.resolve(undefined); + } + + async refresh(req: Request, res: Response): Promise { + const result = await this.getResult(req); + const response = await this.handleResult(result); + res.json(response); + } + + start(): Promise { + return Promise.resolve(undefined); + } + + private async getResult(req: Request): Promise { + const idToken = req.header(ID_TOKEN_HEADER); + const accessToken = req.header(ACCESS_TOKEN_HEADER); + if (idToken === undefined) { + throw new AuthenticationError(`Missing ${ID_TOKEN_HEADER} header`); + } + + return { + fullProfile: this.idTokenToProfile(idToken), + accessToken: accessToken, + }; + } + + private idTokenToProfile(idToken: string) { + const claims = decodeJwt(idToken); + + if (claims.ver !== '2.0') { + throw new Error('id_token is not version 2.0 '); + } + + return { + id: claims.oid, + displayName: claims.name, + provider: 'easyauth', + emails: [{ value: claims.email }], + username: claims.preferred_username, + } as Profile; + } + + private async handleResult( + result: EasyAuthResult, + ): Promise { + const { profile } = await this.authHandler(result, this.resolverContext); + + const backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + this.resolverContext, + ); + + return { + providerInfo: { + accessToken: result.accessToken, + }, + backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity), + profile, + }; + } +} + +/** + * Auth provider integration for Azure EasyAuth + * + * @public + */ +export const easyAuth = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver: SignInResolver; + }; + }) { + return ({ resolverContext }) => { + validateAppServiceConfiguration(process.env); + + if (options?.signIn.resolver === undefined) { + throw new Error( + 'SignInResolver is required to use this authentication provider', + ); + } + + const authHandler = + options.authHandler ?? + (async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + })); + + return new EasyAuthAuthProvider({ + signInResolver: options.signIn.resolver, + authHandler, + resolverContext, + }); + }; + }, +}); + +function validateAppServiceConfiguration(env: NodeJS.ProcessEnv) { + // Based on https://github.com/AzureAD/microsoft-identity-web/blob/f7403779d1a91f4a3fec0ed0993bd82f50f299e1/src/Microsoft.Identity.Web/AppServicesAuth/AppServicesAuthenticationInformation.cs#L38-L59 + // + // It's critical to validate we're really running in a correctly configured Azure App Services, + // As we rely on App Services to manage & validate the ID and Access Token headers + // Without that, this users can be trivially impersonated. + if (env.WEBSITE_SKU === undefined) { + throw new Error('Backstage is not running on Azure App Services'); + } + if (!isEqualCaseInsensitive(env.WEBSITE_AUTH_ENABLED, 'True')) { + throw new Error('Azure App Services does not have authentication enabled'); + } + if ( + !isEqualCaseInsensitive( + env.WEBSITE_AUTH_DEFAULT_PROVIDER, + 'AzureActiveDirectory', + ) + ) { + throw new Error('Authentication provider is not Azure Active Directory'); + } + if (!isEqualCaseInsensitive(process.env.WEBSITE_AUTH_TOKEN_STORE, 'True')) { + throw new Error('Token Store is not enabled'); + } +} + +function isEqualCaseInsensitive(left: string | undefined, right: string) { + return ( + left !== undefined && + right.localeCompare(left, undefined, { sensitivity: 'base' }) === 0 + ); +}