diff --git a/.changeset/fifty-pumpkins-begin.md b/.changeset/fifty-pumpkins-begin.md new file mode 100644 index 0000000000..215afb2807 --- /dev/null +++ b/.changeset/fifty-pumpkins-begin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Refactored the `azure-easyauth` provider to use the implementation from `@backstage/plugin-auth-backend-module-azure-easyauth-provider`. diff --git a/.changeset/wild-rockets-tell.md b/.changeset/wild-rockets-tell.md new file mode 100644 index 0000000000..8fe7c9c3bc --- /dev/null +++ b/.changeset/wild-rockets-tell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-azure-easyauth-provider': minor +--- + +New auth backend module to add `azure-easyauth` provider. Note that as part of this change the default provider ID has been changed from `easyAuth` to `azureEasyAuth`, which means that if you switch to this new module you need to update your app config as well as the `provider` prop of the `ProxiedSignInPage` in the frontend. diff --git a/docs/auth/microsoft/azure-easyauth.md b/docs/auth/microsoft/azure-easyauth.md index 733e8f62d0..5fec3354d7 100644 --- a/docs/auth/microsoft/azure-easyauth.md +++ b/docs/auth/microsoft/azure-easyauth.md @@ -7,62 +7,52 @@ description: Adding Azure's EasyAuth Proxy as an authentication provider in Back The Backstage `core-plugin-api` package comes with a Microsoft authentication provider that can authenticate users using Microsoft Entra ID (formerly Azure Active Directory) for PaaS service hosted in Azure that support Easy Auth, such as Azure App Services. -## Backstage Changes +## Backend Changes -Add the following into your `app-config.yaml` or `app-config.production.yaml` file +Add the following into your `app-config.yaml` under the root `auth` configuration: -```yaml +```yaml title="app-config.yaml" auth: - environment: development providers: - azure-easyauth: {} + azureEasyAuth: + signIn: + resolvers: + - resolver: idMatchingUserEntityAnnotation + - resolver: emailMatchingUserEntityProfileEmail + - resolver: emailLocalPartMatchingUserEntityName ``` -Add a `providerFactories` entry to the router in -`packages/backend/src/plugins/auth.ts`. +The `idMatchingUserEntityAnnotation` is +[a builtin sign-in resolver](../identity-resolver.md#using-builtin-resolvers) from `azureEasyAuth` provider. +It tries to find a user entity with [a `graph.microsoft.com/user-id` annotation](../../features/software-catalog/well-known-annotations.md#graphmicrosoftcomtenant-id-graphmicrosoftcomgroup-id-graphmicrosoftcomuser-id) +which matches the object ID of the user attempting to sign in. +If you want to provide your own sign-in resolver, +see [Building Custom Resolvers](../identity-resolver.md#building-custom-resolvers). -```ts -import { providers } from '@backstage/plugin-auth-backend'; +Add the `@backstage/plugin-auth-backend-module-azure-easyauth-provider` to your backend installation. -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; +```sh +# From your Backstage root directory +yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-azure-easyauth-provider +``` - if (!id) { - throw new Error('User profile contained no id'); - } +Then, add it to your backend's source, - return await ctx.signInWithCatalogUser({ - annotations: { - 'graph.microsoft.com/user-id': id, - }, - }); - }, - }, - }), - }; +```ts title="packages/backend/src/index.ts" +const backend = createBackend(); - return await createRouter({ - logger: env.logger, - config: env.config, - database: env.database, - discovery: env.discovery, - tokenManager: env.tokenManager, - providerFactories: authProviderFactories, - }); -} +backend.add(import('@backstage/plugin-auth-backend')); +// highlight-add-next-line +backend.add( + import('@backstage/plugin-auth-backend-module-azure-easyauth-provider'), +); + +await backend.start(); ``` 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. +`/api/auth/azureEasyAuth/refresh` endpoint. All that's left is to update the frontend +sign-in mechanism to poll that endpoint through the Easy Auth proxy, on the user's behalf. ## Frontend Changes @@ -81,7 +71,7 @@ const app = createApp({ SignInPage: props => { const configApi = useApi(configApiRef); if (configApi.getString('auth.environment') !== 'development') { - return ; + return ; } return ( Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { Profile } from 'passport'; +import { ProxyAuthenticator } from '@backstage/plugin-auth-node'; +import { SignInResolverFactory } from '@backstage/plugin-auth-node'; + +// @public (undocumented) +const authModuleAzureEasyAuthProvider: () => BackendFeature; +export default authModuleAzureEasyAuthProvider; + +// @public (undocumented) +export const azureEasyAuthAuthenticator: ProxyAuthenticator< + void, + AzureEasyAuthResult, + { + accessToken: string | undefined; + } +>; + +// @public (undocumented) +export type AzureEasyAuthResult = { + fullProfile: Profile; + accessToken?: string; +}; + +// @public (undocumented) +export namespace azureEasyAuthSignInResolvers { + const // (undocumented) + idMatchingUserEntityAnnotation: SignInResolverFactory< + AzureEasyAuthResult, + unknown + >; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/auth-backend-module-azure-easyauth-provider/catalog-info.yaml b/plugins/auth-backend-module-azure-easyauth-provider/catalog-info.yaml new file mode 100644 index 0000000000..174f1a52e4 --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-azure-easyauth-provider + title: '@backstage/plugin-auth-backend-module-azure-easyauth-provider' + description: The azure-easyauth-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/auth-backend-module-azure-easyauth-provider/package.json b/plugins/auth-backend-module-azure-easyauth-provider/package.json new file mode 100644 index 0000000000..9cb1e5b72c --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/plugin-auth-backend-module-azure-easyauth-provider", + "version": "0.0.0", + "description": "The azure-easyauth-provider backend module for the auth plugin.", + "backstage": { + "role": "backend-plugin-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth-backend-module-azure-easyauth-provider" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@types/passport": "^1.0.16", + "express": "^4.19.2", + "jose": "^5.0.0", + "passport": "^0.7.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^" + } +} diff --git a/plugins/auth-backend-module-azure-easyauth-provider/src/authenticator.test.ts b/plugins/auth-backend-module-azure-easyauth-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..1a5e4dffa4 --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/src/authenticator.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2024 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 { + azureEasyAuthAuthenticator, + ID_TOKEN_HEADER, + ACCESS_TOKEN_HEADER, +} from './authenticator'; +import { mockServices } from '@backstage/backend-test-utils'; +import { Request } 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); +} + +function mockRequest(headers?: Record) { + return { + header: (name: string) => headers?.[name], + } as unknown as Request; +} + +describe('EasyAuthAuthProvider', () => { + const ctx = azureEasyAuthAuthenticator.initialize({ + config: mockServices.rootConfig(), + }); + + describe('should succeed when', () => { + const claims = { + ver: '2.0', + oid: 'c43063d4-0650-4f3e-ba6b-307473d24dfd', + name: 'Alice Bob', + email: 'alice@bob.com', + preferred_username: 'Another name', + }; + + it('valid id_token provided', async () => { + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt(claims), + }); + await expect( + azureEasyAuthAuthenticator.authenticate({ req: request }, ctx), + ).resolves.toEqual({ + result: { + fullProfile: { + provider: 'easyauth', + id: 'c43063d4-0650-4f3e-ba6b-307473d24dfd', + displayName: 'Alice Bob', + emails: [{ value: 'alice@bob.com' }], + username: 'Another name', + }, + accessToken: undefined, + }, + providerInfo: { + accessToken: undefined, + }, + }); + }); + + it('valid id_token and access_token provided', async () => { + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt(claims), + [ACCESS_TOKEN_HEADER]: 'ACCESS_TOKEN', + }); + await expect( + azureEasyAuthAuthenticator.authenticate({ req: request }, ctx), + ).resolves.toMatchObject({ + result: { accessToken: 'ACCESS_TOKEN' }, + providerInfo: { accessToken: 'ACCESS_TOKEN' }, + }); + }); + }); + + describe('should fail when', () => { + it('id token is missing', async () => { + const request = mockRequest(); + await expect( + azureEasyAuthAuthenticator.authenticate({ req: request }, ctx), + ).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( + azureEasyAuthAuthenticator.authenticate({ req: request }, ctx), + ).rejects.toThrow(JoseErrors.JWTInvalid); + }); + + it('id token is v1', async () => { + const request = mockRequest({ + [ID_TOKEN_HEADER]: await buildJwt({ ver: '1.0' }), + }); + await expect( + azureEasyAuthAuthenticator.authenticate({ req: request }, ctx), + ).rejects.toThrow('id_token is not version 2.0'); + }); + }); +}); diff --git a/plugins/auth-backend-module-azure-easyauth-provider/src/authenticator.ts b/plugins/auth-backend-module-azure-easyauth-provider/src/authenticator.ts new file mode 100644 index 0000000000..855f73073b --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/src/authenticator.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2024 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 { AzureEasyAuthResult } from './types'; +import { Request } from 'express'; +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'; + +/** @public */ +export const azureEasyAuthAuthenticator = createProxyAuthenticator({ + defaultProfileTransform: async (result: AzureEasyAuthResult) => { + return { + profile: { + displayName: result.fullProfile.displayName, + email: result.fullProfile.emails?.[0].value, + picture: result.fullProfile.photos?.[0].value, + }, + }; + }, + initialize() {}, + async authenticate({ req }) { + const result = await getResult(req); + return { + result, + providerInfo: { + accessToken: result.accessToken, + }, + }; + }, +}); + +async function 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: idTokenToProfile(idToken), + accessToken: accessToken, + }; +} + +function 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; +} diff --git a/plugins/auth-backend-module-azure-easyauth-provider/src/index.ts b/plugins/auth-backend-module-azure-easyauth-provider/src/index.ts new file mode 100644 index 0000000000..1737aaa7cc --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/src/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2024 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 { authModuleAzureEasyAuthProvider as default } from './module'; +export { azureEasyAuthAuthenticator } from './authenticator'; +export { azureEasyAuthSignInResolvers } from './resolvers'; +export type { AzureEasyAuthResult } from './types'; diff --git a/plugins/auth-backend-module-azure-easyauth-provider/src/module.test.ts b/plugins/auth-backend-module-azure-easyauth-provider/src/module.test.ts new file mode 100644 index 0000000000..56932bc321 --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/src/module.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2024 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import authPlugin from '@backstage/plugin-auth-backend'; +import { authModuleAzureEasyAuthProvider } from './module'; + +const rootConfig = mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + azureEasyAuth: { + signIn: { + resolvers: [{ resolver: 'idMatchingUserEntityAnnotation' }], + }, + }, + }, + }, + }, +}); + +const features = [authPlugin, authModuleAzureEasyAuthProvider, rootConfig]; + +describe('authModuleAzureEasyAuthProvider', () => { + 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 () => { + await expect(startTestBackend({ features })).rejects.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'; + await expect(startTestBackend({ features })).rejects.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'; + await expect(startTestBackend({ features })).rejects.toThrow( + 'Authentication provider is not Entra ID', + ); + }); + + 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'; + await expect(startTestBackend({ features })).rejects.toThrow( + 'Token Store is not enabled', + ); + }); + + it('should start successfully 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'; + await expect(startTestBackend({ features })).resolves.toBeInstanceOf( + Object, + ); + }); +}); diff --git a/plugins/auth-backend-module-azure-easyauth-provider/src/module.ts b/plugins/auth-backend-module-azure-easyauth-provider/src/module.ts new file mode 100644 index 0000000000..7cd5247648 --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/src/module.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2024 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 { azureEasyAuthAuthenticator } from './authenticator'; +import { azureEasyAuthSignInResolvers } from './resolvers'; + +/** @public */ +export const authModuleAzureEasyAuthProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'azure-easyauth-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + validateAppServiceConfiguration(process.env); + providers.registerProvider({ + providerId: 'azureEasyAuth', + factory: createProxyAuthProviderFactory({ + authenticator: azureEasyAuthAuthenticator, + signInResolverFactories: { + ...commonSignInResolvers, + ...azureEasyAuthSignInResolvers, + }, + }), + }); + }, + }); + }, +}); + +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 (env.WEBSITE_AUTH_ENABLED?.toLocaleLowerCase('en-US') !== 'true') { + throw new Error('Azure App Services does not have authentication enabled'); + } + if ( + env.WEBSITE_AUTH_DEFAULT_PROVIDER?.toLocaleLowerCase('en-US') !== + 'azureactivedirectory' + ) { + throw new Error('Authentication provider is not Entra ID'); + } + if (env.WEBSITE_AUTH_TOKEN_STORE?.toLocaleLowerCase('en-US') !== 'true') { + throw new Error('Token Store is not enabled'); + } +} diff --git a/plugins/auth-backend-module-azure-easyauth-provider/src/resolvers.ts b/plugins/auth-backend-module-azure-easyauth-provider/src/resolvers.ts new file mode 100644 index 0000000000..e9e35420d1 --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/src/resolvers.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2024 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 { AzureEasyAuthResult } from './types'; + +/** @public */ +export namespace azureEasyAuthSignInResolvers { + export const idMatchingUserEntityAnnotation = createSignInResolverFactory({ + create() { + return async (info: SignInInfo, 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, + }, + }); + }; + }, + }); +} diff --git a/plugins/auth-backend-module-azure-easyauth-provider/src/types.ts b/plugins/auth-backend-module-azure-easyauth-provider/src/types.ts new file mode 100644 index 0000000000..851a31e29b --- /dev/null +++ b/plugins/auth-backend-module-azure-easyauth-provider/src/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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 { Profile } from 'passport'; + +/** @public */ +export type AzureEasyAuthResult = { + fullProfile: Profile; + accessToken?: string; +}; diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 2a06f36128..4b2a4d320a 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -10,6 +10,7 @@ import { AuthResolverCatalogUserQuery as AuthResolverCatalogUserQuery_2 } from ' import { AuthResolverContext as AuthResolverContext_2 } from '@backstage/plugin-auth-node'; import { AuthService } from '@backstage/backend-plugin-api'; import { AwsAlbResult as AwsAlbResult_2 } from '@backstage/plugin-auth-backend-module-aws-alb-provider'; +import { AzureEasyAuthResult } from '@backstage/plugin-auth-backend-module-azure-easyauth-provider'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { BackstageSignInResult } from '@backstage/plugin-auth-node'; import { CacheService } from '@backstage/backend-plugin-api'; @@ -199,11 +200,8 @@ export const defaultAuthProviderFactories: { [providerId: string]: AuthProviderFactory_2; }; -// @public (undocumented) -export type EasyAuthResult = { - fullProfile: Profile; - accessToken?: string; -}; +// @public @deprecated (undocumented) +export type EasyAuthResult = AzureEasyAuthResult; // @public @deprecated (undocumented) export const encodeState: typeof encodeOAuthState; @@ -634,9 +632,9 @@ export const providers: Readonly<{ create: ( options?: | { - authHandler?: AuthHandler | undefined; + authHandler?: AuthHandler | undefined; signIn: { - resolver: SignInResolver_2; + resolver: SignInResolver_2; }; } | undefined, diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 6ede92f6e1..f33001df1b 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -45,6 +45,7 @@ "@backstage/errors": "workspace:^", "@backstage/plugin-auth-backend-module-atlassian-provider": "workspace:^", "@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-azure-easyauth-provider": "workspace:^", "@backstage/plugin-auth-backend-module-cloudflare-access-provider": "workspace:^", "@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^", "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", diff --git a/plugins/auth-backend/src/providers/azure-easyauth/index.ts b/plugins/auth-backend/src/providers/azure-easyauth/index.ts index 73abde5f37..de50e32745 100644 --- a/plugins/auth-backend/src/providers/azure-easyauth/index.ts +++ b/plugins/auth-backend/src/providers/azure-easyauth/index.ts @@ -15,4 +15,10 @@ */ export { easyAuth } from './provider'; -export type { EasyAuthResult } from './provider'; +import { AzureEasyAuthResult } from '@backstage/plugin-auth-backend-module-azure-easyauth-provider'; + +/** + * @public + * @deprecated import AzureEasyAuthResult from `@backstage/plugin-auth-backend-module-azure-easyauth-provider` instead + */ +export type EasyAuthResult = AzureEasyAuthResult; diff --git a/plugins/auth-backend/src/providers/azure-easyauth/provider.test.ts b/plugins/auth-backend/src/providers/azure-easyauth/provider.test.ts deleted file mode 100644 index f6a418b633..0000000000 --- a/plugins/auth-backend/src/providers/azure-easyauth/provider.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * 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 } 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'; -import { AuthResolverContext } from '@backstage/plugin-auth-node'; - -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 Entra ID', - ); - }); - - 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 index 6f6fe72307..92c5c183f9 100644 --- a/plugins/auth-backend/src/providers/azure-easyauth/provider.ts +++ b/plugins/auth-backend/src/providers/azure-easyauth/provider.ts @@ -14,114 +14,18 @@ * limitations under the License. */ -import { AuthHandler } 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'; import { - AuthProviderRouteHandlers, - AuthResolverContext, - ClientAuthResponse, SignInResolver, + createProxyAuthProviderFactory, } from '@backstage/plugin-auth-node'; +import { AuthHandler } from '../types'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { + AzureEasyAuthResult, + azureEasyAuthAuthenticator, +} from '@backstage/plugin-auth-backend-module-azure-easyauth-provider'; -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; -}; - -/** @public */ -export type EasyAuthResult = { - fullProfile: Profile; - accessToken?: string; -}; - -export type EasyAuthResponse = ClientAuthResponse<{}>; - -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, - }; - } -} +export type EasyAuthResult = AzureEasyAuthResult; /** * Auth provider integration for Azure EasyAuth @@ -146,48 +50,10 @@ export const easyAuth = createAuthProviderIntegration({ 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, - }); - }; + return createProxyAuthProviderFactory({ + authenticator: azureEasyAuthAuthenticator, + profileTransform: options?.authHandler, + signInResolver: options?.signIn?.resolver, + }); }, }); - -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 (env.WEBSITE_AUTH_ENABLED?.toLowerCase() !== 'true') { - throw new Error('Azure App Services does not have authentication enabled'); - } - if ( - env.WEBSITE_AUTH_DEFAULT_PROVIDER?.toLowerCase() !== 'azureactivedirectory' - ) { - throw new Error('Authentication provider is not Entra ID'); - } - if (process.env.WEBSITE_AUTH_TOKEN_STORE?.toLowerCase() !== 'true') { - throw new Error('Token Store is not enabled'); - } -} diff --git a/yarn.lock b/yarn.lock index 8f8116329a..a80561466f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4793,6 +4793,24 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-azure-easyauth-provider@workspace:^, @backstage/plugin-auth-backend-module-azure-easyauth-provider@workspace:plugins/auth-backend-module-azure-easyauth-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-azure-easyauth-provider@workspace:plugins/auth-backend-module-azure-easyauth-provider" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@types/passport": ^1.0.16 + express: ^4.19.2 + jose: ^5.0.0 + passport: ^0.7.0 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:^, @backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:plugins/auth-backend-module-cloudflare-access-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:plugins/auth-backend-module-cloudflare-access-provider" @@ -5047,6 +5065,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-auth-backend-module-atlassian-provider": "workspace:^" "@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-azure-easyauth-provider": "workspace:^" "@backstage/plugin-auth-backend-module-cloudflare-access-provider": "workspace:^" "@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^" "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" @@ -19420,7 +19439,7 @@ __metadata: languageName: node linkType: hard -"@types/passport@npm:*, @types/passport@npm:^1.0.11, @types/passport@npm:^1.0.3": +"@types/passport@npm:*, @types/passport@npm:^1.0.11, @types/passport@npm:^1.0.16, @types/passport@npm:^1.0.3": version: 1.0.16 resolution: "@types/passport@npm:1.0.16" dependencies: @@ -27856,7 +27875,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2": +"express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2, express@npm:^4.19.2": version: 4.19.2 resolution: "express@npm:4.19.2" dependencies: