Refactor auth-backend azure-easyauth provider

Signed-off-by: YAEGASHI Takeshi <yaegashi@gmail.com>
This commit is contained in:
YAEGASHI Takeshi
2024-04-11 11:53:35 +00:00
committed by Fredrik Adelöw
parent 06a672534d
commit f02fe79de0
7 changed files with 33 additions and 449 deletions
+5
View File
@@ -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`.
+5 -7
View File
@@ -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<EasyAuthResult> | undefined;
authHandler?: AuthHandler<AzureEasyAuthResult> | undefined;
signIn: {
resolver: SignInResolver_2<EasyAuthResult>;
resolver: SignInResolver_2<AzureEasyAuthResult>;
};
}
| undefined,
+1
View File
@@ -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:^",
@@ -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;
@@ -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<EasyAuthResult> = 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<string, string>) {
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);
});
});
@@ -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<EasyAuthResult>;
signInResolver: SignInResolver<EasyAuthResult>;
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<EasyAuthResult>;
private readonly signInResolver: SignInResolver<EasyAuthResult>;
constructor(options: Options) {
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.resolverContext = options.resolverContext;
}
frameHandler(): Promise<void> {
return Promise.resolve(undefined);
}
async refresh(req: Request, res: Response): Promise<void> {
const result = await this.getResult(req);
const response = await this.handleResult(result);
res.json(response);
}
start(): Promise<void> {
return Promise.resolve(undefined);
}
private async getResult(req: Request): Promise<EasyAuthResult> {
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<EasyAuthResponse> {
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<EasyAuthResult>;
};
}) {
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');
}
}
+2 -1
View File
@@ -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:^"