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/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 9782f20576..a80561466f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4793,7 +4793,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-azure-easyauth-provider@workspace:plugins/auth-backend-module-azure-easyauth-provider": +"@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: @@ -5065,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:^"