auth-backend: migrate microsoft provider to separate module

Signed-off-by: Chris Gemmell <chris.gemmell8@gmail.com>
This commit is contained in:
Chris
2023-09-23 15:17:47 +10:00
committed by Chris Gemmell
parent 601b766e3a
commit 2d8f7e82c1
21 changed files with 523 additions and 953 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-auth-backend-module-microsoft-provider': minor
'@backstage/plugin-auth-backend': patch
---
Migrated the Microsoft auth provider to be implemented using the new `@backstage/plugin-auth-backend-module-microsoft-provider` module.
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,8 @@
# Auth Module: Microsoft Provider
This module provides an Microsoft auth provider implementation for `@backstage/plugin-auth-backend`.
## Links
- [Repository](https://gitlab.com/backstage/backstage/tree/master/plugins/auth-backend-module-microsoft-provider)
- [Backstage Project Homepage](https://backstage.io)
@@ -0,0 +1,30 @@
## API Report File for "@backstage/plugin-auth-backend-module-microsoft-provider"
> 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 { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { OAuthAuthenticatorResult } from '@backstage/plugin-auth-node';
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { PassportProfile } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
// @public (undocumented)
const authModuleMicrosoftProvider: () => BackendFeature;
export default authModuleMicrosoftProvider;
// @public (undocumented)
export const microsoftAuthenticator: OAuthAuthenticator<
PassportOAuthAuthenticatorHelper,
PassportProfile
>;
// @public
export namespace microsoftSignInResolvers {
const usernameMatchingUserEntityName: SignInResolverFactory<
OAuthAuthenticatorResult<PassportProfile>,
unknown
>;
}
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth-backend-module-microsoft-provider
title: '@backstage/plugin-auth-backend-module-microsoft-provider'
description: The microsoft-provider backend module for the auth plugin.
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: maintainers
@@ -0,0 +1,34 @@
/*
* 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.
*/
export interface Config {
auth?: {
providers?: {
/** @visibility frontend */
microsoft?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
tenantId: string;
clientSecret: string;
callbackUrl?: string;
};
};
};
};
}
@@ -0,0 +1,26 @@
/*
* Copyright 2023 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 { createBackend } from '@backstage/backend-defaults';
import authPlugin from '@backstage/plugin-auth-backend';
import { authModuleMicrosoftProvider } from '../src';
const backend = createBackend();
backend.add(authPlugin);
backend.add(authModuleMicrosoftProvider);
backend.start();
@@ -0,0 +1,45 @@
{
"name": "@backstage/plugin-auth-backend-module-microsoft-provider",
"description": "The microsoft-provider backend module for the auth plugin.",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"express": "^4.18.2",
"passport": "^0.6.0",
"passport-microsoft": "^1.0.0"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"supertest": "^6.3.3"
},
"configSchema": "config.d.ts",
"files": [
"dist",
"config.d.ts"
]
}
@@ -0,0 +1,74 @@
/*
* Copyright 2023 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 { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import {
createOAuthAuthenticator,
PassportOAuthAuthenticatorHelper,
PassportOAuthDoneCallback,
PassportProfile,
} from '@backstage/plugin-auth-node';
/** @public */
export const microsoftAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const tenantId = config.getString('tenantId');
return PassportOAuthAuthenticatorHelper.from(
new MicrosoftStrategy(
{
clientID: clientId,
clientSecret: clientSecret,
callbackURL: callbackUrl,
tenant: tenantId,
scope: ['user.read'],
},
(
accessToken: string,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: PassportOAuthDoneCallback,
) => {
done(
undefined,
{ fullProfile, params, accessToken },
{ refreshToken },
);
},
),
);
},
async start(input, helper) {
return helper.start(input, {
accessType: 'offline',
prompt: 'consent',
});
},
async authenticate(input, helper) {
return helper.authenticate(input);
},
async refresh(input, helper) {
return helper.refresh(input);
},
});
@@ -0,0 +1,25 @@
/*
* Copyright 2023 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.
*/
/**
* The gitlab-provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export { microsoftAuthenticator } from './authenticator';
export { authModuleMicrosoftProvider } from './module';
export { microsoftSignInResolvers } from './resolvers';
@@ -0,0 +1,81 @@
/*
* Copyright 2023 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 { decodeOAuthState } from '@backstage/plugin-auth-node';
import request from 'supertest';
import { authModuleMicrosoftProvider } from './module';
describe('authModuleMicrosoftProvider', () => {
it('should start', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleMicrosoftProvider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
microsoft: {
development: {
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
tenantId: 'my-tenant-id',
},
},
},
},
},
}),
],
});
const agent = request.agent(server);
const res = await agent.get('/api/auth/microsoft/start?env=development');
expect(res.status).toEqual(302);
const nonceCookie = agent.jar.getCookie('microsoft-nonce', {
domain: 'localhost',
path: '/api/auth/microsoft/handler',
script: false,
secure: false,
});
expect(nonceCookie).toBeDefined();
const startUrl = new URL(res.get('location'));
expect(startUrl.origin).toBe('https://login.microsoftonline.com');
expect(startUrl.pathname).toBe('/my-tenant-id/oauth2/v2.0/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
prompt: 'consent',
response_type: 'code',
scope: 'user.read',
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`,
state: expect.any(String),
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
});
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2023 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,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import { microsoftAuthenticator } from './authenticator';
import { microsoftSignInResolvers } from './resolvers';
/** @public */
export const authModuleMicrosoftProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'microsoft-provider',
register(reg) {
reg.registerInit({
deps: {
providers: authProvidersExtensionPoint,
},
async init({ providers }) {
providers.registerProvider({
providerId: 'microsoft',
factory: createOAuthProviderFactory({
authenticator: microsoftAuthenticator,
signInResolverFactories: {
...microsoftSignInResolvers,
...commonSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,77 @@
/*
* Copyright 2023 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,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
} from '@backstage/plugin-auth-node';
/**
* Available sign-in resolvers for the Microsoft auth provider.
*
* @public
*/
export namespace microsoftSignInResolvers {
/**
* Looks up the user by matching their Microsoft username to the entity name.
*/
export const commonByEmailLocalPartResolver = createSignInResolverFactory({
create() {
return async (
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error(
'Login failed, user profile does not contain an email',
);
}
const [localPart] = profile.email.split('@');
return ctx.signInWithCatalogUser({
entityRef: { name: localPart },
});
};
},
});
export const commonByEmailResolver = createSignInResolverFactory({
create() {
return async (
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error(
'Login failed, user profile does not contain an email',
);
}
return ctx.signInWithCatalogUser({
filter: {
'spec.profile.email': profile.email,
},
});
};
},
});
}
@@ -0,0 +1,25 @@
/*
* 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.
*/
declare module 'passport-microsoft' {
import { Request } from 'express';
import { StrategyCreated } from 'passport';
export class Strategy {
constructor(options: any, verify: any);
authenticate(this: StrategyCreated<this>, req: Request, options?: any): any;
}
}
-12
View File
@@ -179,18 +179,6 @@ export interface Config {
};
};
/** @visibility frontend */
microsoft?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
tenantId: string;
callbackUrl?: string;
};
};
/** @visibility frontend */
onelogin?: {
[authEnv: string]: {
clientId: string;
+1
View File
@@ -42,6 +42,7 @@
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
@@ -1,90 +0,0 @@
/*
* Copyright 2023 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 { FakeMicrosoftAPI } from './fake';
describe('FakeMicrosoftAPI', () => {
const api = new FakeMicrosoftAPI();
describe('#token', () => {
it('exchanges auth codes', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('supports scopes for the first requested audience only', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('someaudience/somescope User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(false);
});
it('special openid scopes do not count towards the 1-audience limit', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('openid offline_access User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('refreshes tokens', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: api.generateRefreshToken(
'email openid profile User.Read',
),
}),
);
expect(
api.tokenHasScope(access_token, 'email openid profile User.Read'),
).toBe(true);
});
it('requires `openid` scope for ID token', () => {
const { id_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(id_token).toBeUndefined();
});
it('requires `offline_access` scope for refresh token', () => {
const { refresh_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(refresh_token).toBeUndefined();
});
});
});
@@ -1,126 +0,0 @@
/*
* Copyright 2023 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 { decodeJwt } from 'jose';
type Claims = { aud: string; scp: string };
export class FakeMicrosoftAPI {
generateAccessToken(scope: string): string {
return this.tokenWithClaims(this.allClaimsForScope(scope)).access_token;
}
generateAuthCode(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
generateRefreshToken(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
token(formData: URLSearchParams): {
access_token: string;
scope: string;
refresh_token?: string;
id_token?: string;
} {
const scopeParameter = formData.get('scope');
const claims =
(scopeParameter && this.allClaimsForScope(scopeParameter)) ??
formData.get('grant_type') === 'refresh_token'
? this.decodeClaims(formData.get('refresh_token')!)
: this.decodeClaims(formData.get('code')!);
return {
...this.tokenWithClaims(claims),
...(this.hasScope(claims, 'offline_access') && {
refresh_token: this.encodeClaims(claims),
}),
...(this.hasScope(claims, 'openid') && {
id_token: 'header.e30K.microsoft',
}),
};
}
tokenHasScope(token: string, scope: string): boolean {
const { aud, scp } = decodeJwt(token);
return this.hasScope({ aud: aud as string, scp: scp as string }, scope);
}
private tokenWithClaims(claims: Claims): {
access_token: string;
scope: string;
} {
const filteredClaims = {
...claims,
scp: claims.scp
.split(' ')
.filter(s => s !== 'offline_access')
.join(' '),
};
return {
access_token: `header.${Buffer.from(
JSON.stringify(filteredClaims),
).toString('base64')}.signature`,
scope: this.scopeFromClaims(filteredClaims),
};
}
private allClaimsForScope(scope: string): Claims {
const scopes = scope.split(' ').map(this.parseScope);
const firstAudience = scopes
.map(({ aud }) => aud)
.find(aud => aud !== 'openid');
return {
aud: firstAudience ?? '00000003-0000-0000-c000-000000000000',
scp: scopes
.filter(({ aud }) => aud === 'openid' || aud === firstAudience)
.map(({ scp }) => scp)
.join(' '),
};
}
// auth codes and refresh tokens in this fake system are base64-encoded JSON
// strings of claims
private encodeClaims(claims: Claims): string {
return Buffer.from(JSON.stringify(claims)).toString('base64');
}
private decodeClaims(encoded: string): Claims {
return JSON.parse(Buffer.from(encoded, 'base64').toString());
}
private hasScope(claims: Claims, scope: string): boolean {
return this.scopeFromClaims(claims).includes(scope);
}
private parseScope(s: string): Claims {
if (s.includes('/')) {
const [aud, scp] = s.split('/');
return { aud, scp };
}
switch (s) {
case 'email':
case 'openid':
case 'offline_access':
case 'profile': {
return { aud: 'openid', scp: s };
}
default:
return { aud: '00000003-0000-0000-c000-000000000000', scp: s };
}
}
private scopeFromClaims(claims: Claims): string {
return claims.scp
.split(' ')
.map(this.parseScope)
.map(({ aud, scp }) =>
aud === 'openid' ||
claims.aud === '00000003-0000-0000-c000-000000000000'
? scp
: `${claims.aud}/${scp}`,
)
.join(' ');
}
}
@@ -1,450 +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 { microsoft } from './provider';
import { getVoidLogger } from '@backstage/backend-common';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { AuthProviderRouteHandlers, AuthResolverContext } from '../types';
import express from 'express';
import crypto from 'crypto';
import { FakeMicrosoftAPI } from './__testUtils__/fake';
describe('MicrosoftAuthProvider', () => {
const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64
const state = Buffer.from(
`nonce=${encodeURIComponent(nonce)}&env=development`,
).toString('hex');
const mockBackstageToken = `header.${Buffer.from(
JSON.stringify({ sub: 'user:default/mock' }),
'utf8',
).toString('base64')}.backstage`;
const server = setupServer();
const microsoftApi = new FakeMicrosoftAPI();
let provider: AuthProviderRouteHandlers;
let response: jest.Mocked<express.Response>;
setupRequestMockHandlers(server);
beforeEach(() => {
provider = microsoft.create({
signIn: {
resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(),
},
})({
providerId: 'microsoft',
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
globalConfig: {
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
},
config: new ConfigReader({
development: {
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: async _ => ({
token: mockBackstageToken,
}),
} as AuthResolverContext,
}) as AuthProviderRouteHandlers;
server.use(
rest.post(
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token',
async (req, res, ctx) => {
return res(
ctx.json({
...microsoftApi.token(new URLSearchParams(await req.text())),
token_type: 'Bearer',
expires_in: 123,
ext_expires_in: 123,
}),
);
},
),
rest.get('https://graph.microsoft.com/v1.0/me/', (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
return res(
ctx.json({
id: 'conrad',
displayName: 'Conrad',
surname: 'Ribas',
givenName: 'Francisco',
mail: 'conrad@example.com',
}),
);
}),
rest.get(
'https://graph.microsoft.com/v1.0/me/photos/*',
async (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer;
return res(
ctx.set('Content-Length', imageBuffer.byteLength.toString()),
ctx.set('Content-Type', 'image/jpeg'),
ctx.body(imageBuffer),
);
},
),
);
response = {
cookie: jest.fn(),
end: jest.fn(),
json: jest.fn(),
setHeader: jest.fn(),
status: jest.fn(),
} as unknown as jest.Mocked<express.Response>;
response.status.mockReturnValue(response);
});
describe('#start', () => {
const randomBytes = jest.spyOn(
crypto,
'randomBytes',
) as unknown as jest.MockedFunction<(size: number) => Buffer>;
afterEach(() => {
randomBytes.mockRestore();
});
it('redirects to authorize URL', async () => {
randomBytes.mockReturnValue(Buffer.from(nonce, 'base64'));
await provider.start(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
} as unknown as express.Request,
response,
);
expect(response.setHeader).toHaveBeenCalledWith(
'Location',
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' +
'?response_type=code' +
`&redirect_uri=${encodeURIComponent(
'http://backstage.test/api/auth/microsoft/handler/frame',
)}` +
`&scope=${encodeURIComponent('email openid profile User.Read')}` +
`&state=${state}` +
'&client_id=clientId',
);
});
});
describe('#handle', () => {
it('returns provider info and profile with photo data', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode('aks-audience/user.read'),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
scope: 'aks-audience/user.read',
expiresInSeconds: 123,
},
profile: {},
},
}),
),
),
);
});
it('sets refresh token', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email offline_access openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.cookie).toHaveBeenCalledWith(
'microsoft-refresh-token',
microsoftApi.generateRefreshToken(
'email offline_access openid profile User.Read',
),
{
domain: 'backstage.test',
httpOnly: true,
maxAge: 86400000000,
path: '/api/auth/microsoft',
sameSite: 'lax',
secure: false,
},
);
});
it('omits photo data when fetching it fails', async () => {
server.use(
rest.get('https://graph.microsoft.com/v1.0/me/photos/*', (_, res) =>
res.networkError('remote hung up'),
),
);
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
});
describe('#refresh', () => {
it('returns provider info and profile with photo data', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
}),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'aks-audience/user.read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'aks-audience/user.read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
expiresInSeconds: 123,
scope: 'aks-audience/user.read',
},
profile: {},
});
});
it('returns backstage identity', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
backstageIdentity: expect.objectContaining({
token: mockBackstageToken,
}),
}),
);
});
});
});
@@ -14,218 +14,18 @@
* limitations under the License.
*/
import express from 'express';
import passport from 'passport';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthHandler,
OAuthStartResponse,
SignInResolver,
AuthResolverContext,
} from '../types';
import { SignInResolver, AuthHandler } from '../types';
import { OAuthResult } from '../../lib/oauth';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { createOAuthProviderFactory } from '@backstage/plugin-auth-node';
import {
commonByEmailLocalPartResolver,
commonByEmailResolver,
} from '../resolvers';
import { LoggerService } from '@backstage/backend-plugin-api';
import fetch from 'node-fetch';
import { decodeJwt } from 'jose';
import { Profile as PassportProfile } from 'passport';
import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
logger: LoggerService;
resolverContext: AuthResolverContext;
authorizationUrl?: string;
tokenUrl?: string;
};
export class MicrosoftAuthProvider implements OAuthHandlers {
private readonly _strategy: MicrosoftStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly logger: LoggerService;
private readonly resolverContext: AuthResolverContext;
constructor(options: Options) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.logger = options.logger;
this.resolverContext = options.resolverContext;
this._strategy = new MicrosoftStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
passReqToCallback: false,
skipUserProfile: (
accessToken: string,
done: (err: unknown, skip: boolean) => void,
) => {
done(null, this.skipUserProfile(accessToken));
},
},
(
accessToken: any,
refreshToken: any,
params: any,
fullProfile: passport.Profile,
done: PassportDoneCallback<OAuthResult, PrivateInfo>,
) => {
done(undefined, { fullProfile, accessToken, params }, { refreshToken });
},
);
}
private skipUserProfile = (accessToken: string): boolean => {
const { aud, scp } = decodeJwt(accessToken);
const hasGraphReadScope =
aud === '00000003-0000-0000-c000-000000000000' &&
(scp as string)
.split(' ')
.map(s => s.toLowerCase())
.includes('user.read');
return !hasGraphReadScope;
};
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
return await executeRedirectStrategy(req, this._strategy, {
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
return {
response: await this.handleResult({
params,
accessToken,
...(!this.skipUserProfile(accessToken) && {
fullProfile: await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
),
}),
}),
refreshToken,
};
}
private async handleResult(result: {
fullProfile?: PassportProfile;
params: {
id_token?: string;
scope: string;
expires_in: number;
};
accessToken: string;
refreshToken?: string;
}): Promise<OAuthResponse> {
let profile = {};
if (result.fullProfile) {
const photo = await this.getUserPhoto(result.accessToken);
result.fullProfile.photos = photo ? [{ value: photo }] : undefined;
({ profile } = await this.authHandler(
result as OAuthResult,
this.resolverContext,
));
}
const expiresInSeconds =
result.params.expires_in === undefined
? BACKSTAGE_SESSION_EXPIRATION
: Math.min(result.params.expires_in, BACKSTAGE_SESSION_EXPIRATION);
return {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds,
...{ idToken: result.params.id_token },
},
profile,
...(result.fullProfile &&
this.signInResolver && {
backstageIdentity: await this.signInResolver(
{ result: result as OAuthResult, profile },
this.resolverContext,
),
}),
};
}
private async getUserPhoto(accessToken: string): Promise<string | undefined> {
try {
const res = await fetch(
'https://graph.microsoft.com/v1.0/me/photos/48x48/$value',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
const data = await res.buffer();
return `data:image/jpeg;base64,${data.toString('base64')}`;
} catch (error) {
this.logger.warn(
`Could not retrieve user profile photo from Microsoft Graph API: ${error}`,
);
return undefined;
}
}
}
adaptLegacyOAuthHandler,
adaptLegacyOAuthSignInResolver,
} from '../../lib/legacy';
import { microsoftAuthenticator } from '@backstage/plugin-auth-backend-module-microsoft-provider';
/**
* Auth provider integration for Microsoft auth
* Auth provider integration for GitLab auth
*
* @public
*/
@@ -241,75 +41,13 @@ export const microsoft = createAuthProviderIntegration({
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<OAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, logger, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantId = envConfig.getString('tenantId');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile ?? {}, params.id_token),
});
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
authHandler,
signInResolver: options?.signIn?.resolver,
logger,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
resolvers: {
/**
* Looks up the user by matching their email local part to the entity name.
*/
emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver,
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
/**
* Looks up the user by matching their email to the `microsoft.com/email` annotation.
*/
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult> {
return async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Microsoft profile contained no email');
}
return ctx.signInWithCatalogUser({
annotations: {
'microsoft.com/email': profile.email,
},
});
};
},
return createOAuthProviderFactory({
authenticator: microsoftAuthenticator,
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
});
},
});
+19
View File
@@ -4780,6 +4780,24 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-microsoft-provider@workspace:^, @backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-defaults": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
express: ^4.18.2
passport: ^0.6.0
passport-microsoft: ^1.0.0
supertest: ^6.3.3
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-oauth2-provider@workspace:^, @backstage/plugin-auth-backend-module-oauth2-provider@workspace:plugins/auth-backend-module-oauth2-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-oauth2-provider@workspace:plugins/auth-backend-module-oauth2-provider"
@@ -4815,6 +4833,7 @@ __metadata:
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"