Refactor auth-backend azure-easyauth provider
Signed-off-by: YAEGASHI Takeshi <yaegashi@gmail.com>
This commit is contained in:
committed by
Fredrik Adelöw
parent
06a672534d
commit
f02fe79de0
@@ -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`.
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user