support non-microsoft graph scopes

Signed-off-by: Jamie Klassen <jklassen@vmware.com>
This commit is contained in:
Jamie Klassen
2023-02-14 09:27:08 -05:00
parent 646025786e
commit 475abd1dc3
3 changed files with 161 additions and 60 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-auth-backend': patch
---
The `microsoft` (i.e. Azure) auth provider now supports negotiating tokens for
Azure resources besides Microsoft Graph (e.g. AKS, Virtual Machines, Machine
Learning Services, etc.). When the `/frame/handler` endpoint is called with an
authorization code for a non-Microsoft Graph scope, the user profile will not be
fetched. Similarly no user profile or photo data will be fetched by the backend
if the `/refresh` endpoint is called with the `scope` query parameter strictly
containing scopes for resources besides Microsoft Graph.
@@ -39,7 +39,11 @@ describe('MicrosoftAuthProvider', () => {
setupRequestMockHandlers(server);
beforeEach(() => {
provider = microsoft.create()({
provider = microsoft.create({
signIn: {
resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(),
},
})({
providerId: 'microsoft',
globalConfig: {
baseUrl: 'http://backstage.test/api/auth',
@@ -54,8 +58,15 @@ describe('MicrosoftAuthProvider', () => {
},
}),
logger: getVoidLogger(),
resolverContext: {} as AuthResolverContext,
});
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: _ =>
Promise.resolve({
token: 'header.e30K.backstage',
}),
} as AuthResolverContext,
}) as AuthProviderRouteHandlers;
server.use(
rest.post(
@@ -147,8 +158,10 @@ describe('MicrosoftAuthProvider', () => {
'Location',
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' +
'?response_type=code' +
'&redirect_uri=http%3A%2F%2Fbackstage.test%2Fapi%2Fauth%2Fmicrosoft%2Fhandler%2Fframe' +
'&scope=email%20openid%20profile%20User.Read' +
`&redirect_uri=${encodeURIComponent(
'http://backstage.test/api/auth/microsoft/handler/frame',
)}` +
`&scope=${encodeURIComponent('email openid profile User.Read')}` +
`&state=${state}` +
'&client_id=clientId',
);
@@ -180,18 +193,58 @@ describe('MicrosoftAuthProvider', () => {
type: 'authorization_response',
response: {
providerInfo: {
idToken: 'header.e30K.microsoft',
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: 'header.e30K.backstage',
identity: { type: 'user', 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: {},
},
}),
),
@@ -262,17 +315,21 @@ describe('MicrosoftAuthProvider', () => {
type: 'authorization_response',
response: {
providerInfo: {
idToken: 'header.e30K.microsoft',
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: 'header.e30K.backstage',
identity: { type: 'user', ownershipEntityRefs: [] },
},
},
}),
),
@@ -306,45 +363,50 @@ describe('MicrosoftAuthProvider', () => {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
scope: 'email openid profile User.Read',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
}),
);
});
it('returns backstage identity when sign-in resolver is configured', async () => {
provider = microsoft.create({
signIn: {
resolver: _ =>
Promise.resolve({
token: 'protectedheader.e30K.signature',
}),
},
})({
providerId: 'microsoft',
globalConfig: {
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
},
config: new ConfigReader({
development: {
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
it('returns access token for non-microsoft graph scope', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'aks-audience/user.read',
},
}),
logger: getVoidLogger(),
resolverContext: {} as AuthResolverContext,
}) as AuthProviderRouteHandlers;
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: {
@@ -365,7 +427,7 @@ describe('MicrosoftAuthProvider', () => {
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
backstageIdentity: expect.objectContaining({
token: 'protectedheader.e30K.signature',
token: 'header.e30K.backstage',
}),
}),
);
@@ -49,6 +49,8 @@ import {
} from '../resolvers';
import { Logger } from 'winston';
import fetch from 'node-fetch';
import { decodeJwt } from 'jose';
import { Profile as PassportProfile } from 'passport';
const BACKSTAGE_SESSION_EXPIRATION = 3600;
@@ -86,6 +88,12 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
passReqToCallback: false,
skipUserProfile: (
accessToken: string,
done: (err: unknown, skip: boolean) => void,
) => {
done(null, this.skipUserProfile(accessToken));
},
},
(
accessToken: any,
@@ -99,6 +107,17 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
);
}
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,
@@ -126,53 +145,62 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
...(!this.skipUserProfile(accessToken) && {
fullProfile: await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
),
}),
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
const photo = await this.getUserPhoto(result.accessToken);
result.fullProfile.photos = photo ? [{ value: photo }] : undefined;
const { profile } = await this.authHandler(result, this.resolverContext);
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);
const response: OAuthResponse = {
return {
providerInfo: {
idToken: result.params.id_token,
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,
),
}),
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
this.resolverContext,
);
}
return response;
}
private async getUserPhoto(accessToken: string): Promise<string | undefined> {
@@ -236,7 +264,7 @@ export const microsoft = createAuthProviderIntegration({
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
profile: makeProfileInfo(fullProfile ?? {}, params.id_token),
});
const provider = new MicrosoftAuthProvider({