Merge pull request #16323 from jamieklassen/multiaudience-microsoft

Microsoft auth provider manages tokens for multiple resources
This commit is contained in:
Patrik Oldsberg
2023-04-04 17:48:53 +02:00
committed by GitHub
14 changed files with 1030 additions and 187 deletions
@@ -0,0 +1,90 @@
/*
* 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();
});
});
});
@@ -0,0 +1,126 @@
/*
* 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(' ');
}
}
@@ -14,145 +14,423 @@
* limitations under the License.
*/
import { MicrosoftAuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
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 { AuthResolverContext } from '../types';
import { AuthProviderRouteHandlers, AuthResolverContext } from '../types';
import express from 'express';
import crypto from 'crypto';
import { FakeMicrosoftAPI } from './__testUtils__/fake';
jest.mock('../../lib/passport/PassportStrategyHelper', () => {
return {
executeFrameHandlerStrategy: jest.fn(),
};
});
describe('MicrosoftAuthProvider', () => {
const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64
const state = Buffer.from(
`nonce=${encodeURIComponent(nonce)}&env=development`,
).toString('hex');
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
const server = setupServer();
const microsoftApi = new FakeMicrosoftAPI();
let provider: AuthProviderRouteHandlers;
let response: jest.Mocked<express.Response>;
const mockResult = {
result: {
fullProfile: {
emails: [
{
type: 'work',
value: 'conrad@example.com',
},
],
displayName: 'Conrad',
name: {
familyName: 'Ribas',
givenName: 'Francisco',
setupRequestMockHandlers(server);
beforeEach(() => {
provider = microsoft.create({
signIn: {
resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(),
},
id: 'conrad',
provider: 'microsoft',
photos: [
{
value: 'some-data',
})({
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',
},
],
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
};
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: _ =>
Promise.resolve({
token: 'header.e30K.backstage',
}),
} as AuthResolverContext,
}) as AuthProviderRouteHandlers;
const server = setupServer();
setupRequestMockHandlers(server);
const setupHandlers = () => {
server.use(
rest.get(
'https://graph.microsoft.com/v1.0/me/photos/*',
async (_, res, ctx) => {
const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer;
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.set('Content-Length', imageBuffer.byteLength.toString()),
ctx.set('Content-Type', 'image/jpeg'),
ctx.body(imageBuffer),
ctx.json({
id: 'conrad',
displayName: 'Conrad',
surname: 'Ribas',
givenName: 'Francisco',
mail: 'conrad@example.com',
}),
);
},
),
);
};
describe('createMicrosoftProvider', () => {
it('should auth', async () => {
setupHandlers();
const provider = new MicrosoftAuthProvider({
logger: getVoidLogger(),
resolverContext: {} as AuthResolverContext,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://microsoft.com/lols',
},
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
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();
});
mockFrameHandler.mockResolvedValueOnce(mockResult);
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://microsoft.com/lols',
},
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',
);
});
});
it('should return the base64 encoded photo data of the profile', async () => {
setupHandlers();
const provider = new MicrosoftAuthProvider({
logger: getVoidLogger(),
resolverContext: {} as AuthResolverContext,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://microsoft.com/lols',
},
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
// define resolver to return user `info` for photo validation
signInResolver: async (info, _) => {
return {
id: 'user.name',
token: 'token',
info: info,
};
},
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: '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: {},
},
}),
),
),
);
});
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: 'header.e30K.backstage',
identity: { type: 'user', 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: 'header.e30K.backstage',
}),
}),
);
});
mockFrameHandler.mockResolvedValueOnce(mockResult);
const { response } = await provider.handler({} as any);
const overloadedIdentity = response.backstageIdentity as any;
const photo = overloadedIdentity.info.result.fullProfile.photos[0];
expect(photo.value).toEqual('data:image/jpeg;base64,aG93ZHk=');
});
});
@@ -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({