can get non-microsoft graph tokens via popup
These tokens will need to be requested on demand with a popup every time: Azure does allow you to get a refresh token from its `/token` endpoint for a set of Microsoft Graph scopes and use that refresh token to get access tokens for other resources, but our session manager isn't quite subtle enough to handle use cases like that yet. Signed-off-by: Jamie Klassen <jklassen@vmware.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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 MicrosoftAuth from './MicrosoftAuth';
|
||||
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
|
||||
import { UrlPatternDiscovery } from '../../DiscoveryApi';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { rest } from 'msw';
|
||||
|
||||
describe('MicrosoftAuth', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
const microsoftAuth = MicrosoftAuth.create({
|
||||
configApi: new ConfigReader(undefined),
|
||||
oauthRequestApi: new MockOAuthApi(),
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://backstage.test/api/{{ pluginId }}',
|
||||
),
|
||||
});
|
||||
|
||||
const toHaveJWTClaims = function toHaveJWTClaims(
|
||||
this: jest.MatcherContext,
|
||||
received: string,
|
||||
expected: Record<string, any>,
|
||||
): jest.CustomMatcherResult {
|
||||
let parsedClaims: Record<string, any>;
|
||||
try {
|
||||
parsedClaims = JSON.parse(
|
||||
Buffer.from(received.split('.')[1], 'base64').toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () =>
|
||||
`Expected JWT with claims: ${this.utils.printExpected(
|
||||
expected,
|
||||
)}\nReceived invalid JWT ${this.utils.printReceived(
|
||||
received,
|
||||
)}\nError: ${e}`,
|
||||
};
|
||||
}
|
||||
const expectedResult = expect.objectContaining(expected);
|
||||
return {
|
||||
pass: this.equals(parsedClaims, expectedResult),
|
||||
message: () =>
|
||||
`Expected JWT with claims: ${this.utils.printExpected(
|
||||
expected,
|
||||
)}\nReceived JWT with claims: ${this.utils.printReceived(
|
||||
parsedClaims,
|
||||
)}\n\n${this.utils.diff(expectedResult, parsedClaims)}`,
|
||||
};
|
||||
};
|
||||
expect.extend({ toHaveJWTClaims });
|
||||
|
||||
describe('with a refresh token', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://backstage.test/api/auth/microsoft/refresh',
|
||||
(req, res, ctx) => {
|
||||
const scope =
|
||||
req.url.searchParams.get('scope') ||
|
||||
'openid profile email User.Read';
|
||||
return res(
|
||||
ctx.json({
|
||||
providerInfo: {
|
||||
accessToken: `header.${Buffer.from(
|
||||
JSON.stringify({
|
||||
aud: '00000003-0000-0000-c000-000000000000',
|
||||
scp: 'openid profile email User.Read',
|
||||
}),
|
||||
).toString('base64')}.signature`,
|
||||
scope,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('gets access token for Microsoft Graph', async () => {
|
||||
const accessToken = await microsoftAuth.getAccessToken();
|
||||
|
||||
expect(accessToken).toHaveJWTClaims({
|
||||
aud: '00000003-0000-0000-c000-000000000000',
|
||||
scp: 'openid profile email User.Read',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a refresh token', () => {
|
||||
let mockRequester: jest.Mock;
|
||||
let sut: typeof microsoftAuthApiRef.T;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequester = jest.fn();
|
||||
sut = MicrosoftAuth.create({
|
||||
configApi: new ConfigReader(undefined),
|
||||
oauthRequestApi: {
|
||||
createAuthRequester: jest.fn().mockReturnValue(mockRequester),
|
||||
authRequest$: jest.fn(),
|
||||
},
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://backstage.test/api/{{ pluginId }}',
|
||||
),
|
||||
});
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://backstage.test/api/auth/microsoft/refresh',
|
||||
(_, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(401),
|
||||
ctx.json({
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message:
|
||||
'Refresh failed; caused by InputError: Missing session cookie',
|
||||
cause: {
|
||||
name: 'InputError',
|
||||
message: 'Missing session cookie',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/api/auth/microsoft/refresh?env=development',
|
||||
},
|
||||
response: {
|
||||
statusCode: 401,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('gets access token for Microsoft Graph via popup', async () => {
|
||||
await sut.getAccessToken();
|
||||
|
||||
expect(mockRequester).toHaveBeenCalledWith(
|
||||
new Set(['User.Read', 'email', 'offline_access', 'openid', 'profile']),
|
||||
);
|
||||
});
|
||||
|
||||
it('gets access token for other azure resources via popup', async () => {
|
||||
await sut.getAccessToken('azure-resource/scope');
|
||||
|
||||
expect(mockRequester).toHaveBeenCalledWith(
|
||||
new Set(['azure-resource/scope']),
|
||||
);
|
||||
});
|
||||
|
||||
it('requests scopes for resource ID when scope contains a resource URI', async () => {
|
||||
await sut.getAccessToken('api://customApiClientId/some.scope');
|
||||
|
||||
expect(mockRequester).toHaveBeenCalledWith(
|
||||
new Set(['api://customApiClientId/some.scope']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
microsoftAuthApiRef,
|
||||
AuthRequestOptions,
|
||||
AuthProviderInfo,
|
||||
ConfigApi,
|
||||
DiscoveryApi,
|
||||
OAuthRequestApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
@@ -36,7 +37,8 @@ const DEFAULT_PROVIDER = {
|
||||
* @public
|
||||
*/
|
||||
export default class MicrosoftAuth {
|
||||
private oauth2: Record<string, OAuth2>;
|
||||
private oauth2: { [aud: string]: OAuth2 };
|
||||
private configApi: ConfigApi | undefined;
|
||||
private environment: string;
|
||||
private provider: AuthProviderInfo;
|
||||
private oauthRequestApi: OAuthRequestApi;
|
||||
@@ -85,8 +87,54 @@ export default class MicrosoftAuth {
|
||||
return this.oauth2[MicrosoftAuth.MicrosoftGraphID];
|
||||
}
|
||||
|
||||
getAccessToken(scope?: string | string[], options?: AuthRequestOptions) {
|
||||
return this.microsoftGraph().getAccessToken(scope, options);
|
||||
private static resourceForScopes(scope: string): Promise<string> {
|
||||
const audience =
|
||||
scope
|
||||
.split(' ')
|
||||
.map(MicrosoftAuth.resourceForScope)
|
||||
.find(aud => aud !== 'openid') ?? MicrosoftAuth.MicrosoftGraphID;
|
||||
return Promise.resolve(audience);
|
||||
}
|
||||
|
||||
private static resourceForScope(scope: string): string {
|
||||
const groups = scope.match(/^(?<resourceURI>.*)\/(?<scp>[^\/]*)$/)?.groups;
|
||||
if (groups) {
|
||||
const { resourceURI } = groups;
|
||||
const aud = resourceURI.replace(/^api:\/\//, '');
|
||||
return aud;
|
||||
}
|
||||
switch (scope) {
|
||||
case 'email':
|
||||
case 'openid':
|
||||
case 'offline_access':
|
||||
case 'profile': {
|
||||
return 'openid';
|
||||
}
|
||||
default:
|
||||
return MicrosoftAuth.MicrosoftGraphID;
|
||||
}
|
||||
}
|
||||
|
||||
async getAccessToken(
|
||||
scope?: string | string[],
|
||||
options?: AuthRequestOptions,
|
||||
): Promise<string> {
|
||||
const aud =
|
||||
scope === undefined
|
||||
? MicrosoftAuth.MicrosoftGraphID
|
||||
: await MicrosoftAuth.resourceForScopes(
|
||||
Array.isArray(scope) ? scope.join(' ') : scope,
|
||||
);
|
||||
if (!(aud in this.oauth2)) {
|
||||
this.oauth2[aud] = OAuth2.create({
|
||||
configApi: this.configApi,
|
||||
discoveryApi: this.discoveryApi,
|
||||
oauthRequestApi: this.oauthRequestApi,
|
||||
provider: this.provider,
|
||||
environment: this.environment,
|
||||
});
|
||||
}
|
||||
return this.oauth2[aud].getAccessToken(scope, options);
|
||||
}
|
||||
|
||||
getIdToken(options?: AuthRequestOptions) {
|
||||
|
||||
Reference in New Issue
Block a user