fix(auth/microsoft): fix missing profile photo and access token for foreign scopes

This change re-adds the loading of the logged-in user's profile picture
as well as the "skip profile" logic for foreign scopes
which got lost while migrating the Microsoft auth provider to the new module.

Additionally, it uses the 96x96 size instead of 48x48.
The image is used on the profile card on the settings page (`/settings`)
rendered as 96x96 and was blurry due to the upscaling.

Replaces the `types.d.ts` file with type declarations with the use of
`@types/passport-microsoft`.

Closes: #20677
Closes: #20699
Signed-off-by: Patrick Jungermann <Patrick.Jungermann@gmail.com>
This commit is contained in:
Patrick Jungermann
2023-10-20 15:41:26 +02:00
parent cc7867a9e9
commit fde212dd10
9 changed files with 632 additions and 27 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
---
Re-add the missing profile photo
as well as access token retrieval for foreign scopes.
Additionally, we switch from previously 48x48 to 96x96
which is the size used at the profile card.
@@ -27,6 +27,8 @@
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"express": "^4.18.2",
"jose": "^4.6.0",
"node-fetch": "^2.6.7",
"passport": "^0.6.0",
"passport-microsoft": "^1.0.0"
},
@@ -34,7 +36,10 @@
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@types/passport-microsoft": "^1.0.0",
"msw": "^1.0.0",
"supertest": "^6.3.3"
},
"configSchema": "config.d.ts",
@@ -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,137 @@
/*
* 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(' ');
}
}
@@ -0,0 +1,277 @@
/*
* 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import {
encodeOAuthState,
OAuthAuthenticatorAuthenticateInput,
OAuthAuthenticatorRefreshInput,
OAuthAuthenticatorStartInput,
OAuthState,
PassportOAuthAuthenticatorHelper,
} from '@backstage/plugin-auth-node';
import express from 'express';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { FakeMicrosoftAPI } from './__testUtils__/fake';
import { microsoftAuthenticator } from './authenticator';
describe('microsoftAuthenticator', () => {
const oauthState: OAuthState = {
nonce: 'AAAAAAAAAAAAAAAAAAAAAA==',
env: 'development',
};
const scope = 'email openid profile User.Read';
const state = encodeOAuthState(oauthState);
const photo = 'data:image/jpeg;base64,aG93ZHk=';
const microsoftApi = new FakeMicrosoftAPI();
const server = setupServer();
setupRequestMockHandlers(server);
let implementation: {
domainHint: string | undefined;
helper: PassportOAuthAuthenticatorHelper;
};
beforeEach(() => {
jest.clearAllMocks();
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),
);
},
),
);
implementation = microsoftAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
}),
});
});
describe('#start', () => {
it('redirects to authorize URL', async () => {
const startRequest: OAuthAuthenticatorStartInput = {
scope,
state,
req: {
method: 'GET',
url: 'test',
} as unknown as express.Request,
};
const startResponse = await microsoftAuthenticator.start(
startRequest,
implementation,
);
expect(startResponse.url).toBe(
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' +
'?response_type=code' +
`&redirect_uri=${encodeURIComponent(
'https://backstage.test/callback',
)}` +
`&scope=${encodeURIComponent(scope)}` +
`&state=${state}` +
'&client_id=clientId',
);
});
});
describe('#authenticate', () => {
const createAuthenticateRequest = (
scopeForRequest: string,
): OAuthAuthenticatorAuthenticateInput => {
const authCode = microsoftApi.generateAuthCode(scopeForRequest);
return {
req: {
method: 'GET',
url: 'test',
query: {
code: authCode,
state,
},
session: {},
cookies: {
'microsoft-nonce': oauthState.nonce,
},
} as unknown as express.Request,
};
};
it('returns provider info and profile with photo data', async () => {
const authenticateResponse = await microsoftAuthenticator.authenticate(
createAuthenticateRequest(scope),
implementation,
);
const profile = authenticateResponse.fullProfile;
expect(profile.displayName).toBe('Conrad');
expect(profile.emails).toStrictEqual([
{
type: 'work',
value: 'conrad@example.com',
},
]);
expect(profile.photos).toStrictEqual([{ value: photo }]);
});
it('returns access token for non-microsoft graph scope', async () => {
const foreignScope = 'aks-audience/user.read';
const authenticateResponse = await microsoftAuthenticator.authenticate(
createAuthenticateRequest(foreignScope),
implementation,
);
expect(authenticateResponse.fullProfile).toBeUndefined();
expect(authenticateResponse.session.accessToken).toBe(
microsoftApi.generateAccessToken(foreignScope),
);
});
it('sets refresh token', async () => {
const refreshScope = 'email offline_access openid profile User.Read';
const authenticateResponse = await microsoftAuthenticator.authenticate(
createAuthenticateRequest(refreshScope),
implementation,
);
const session = authenticateResponse.session;
expect(session.refreshToken).toBe(
microsoftApi.generateRefreshToken(refreshScope),
);
});
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'),
),
);
const authenticateResponse = await microsoftAuthenticator.authenticate(
createAuthenticateRequest(scope),
implementation,
);
const profile = authenticateResponse.fullProfile;
expect(profile.displayName).toBe('Conrad');
expect(profile.emails).toStrictEqual([
{
type: 'work',
value: 'conrad@example.com',
},
]);
expect(profile.photos).toBeUndefined();
});
});
describe('#refresh', () => {
const createRefreshRequest = (
scopeForRequest: string,
): OAuthAuthenticatorRefreshInput => {
return {
scope: scopeForRequest,
refreshToken: microsoftApi.generateRefreshToken(scopeForRequest),
req: {} as unknown as express.Request,
};
};
it('returns provider info and profile with photo data', async () => {
const refreshResponse = await microsoftAuthenticator.refresh(
createRefreshRequest(scope),
implementation,
);
const profile = refreshResponse.fullProfile;
expect(profile.displayName).toBe('Conrad');
expect(profile.emails).toStrictEqual([
{
type: 'work',
value: 'conrad@example.com',
},
]);
expect(profile.photos).toStrictEqual([{ value: photo }]);
});
it('returns access token for non-microsoft graph scope', async () => {
const foreignScope = 'aks-audience/user.read';
const refreshResponse = await microsoftAuthenticator.refresh(
createRefreshRequest(foreignScope),
implementation,
);
expect(refreshResponse.fullProfile).toBeUndefined();
expect(refreshResponse.session.accessToken).toBe(
microsoftApi.generateAccessToken(foreignScope),
);
});
});
});
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import {
createOAuthAuthenticator,
PassportOAuthAuthenticatorHelper,
PassportOAuthDoneCallback,
PassportProfile,
} from '@backstage/plugin-auth-node';
import { ExtendedMicrosoftStrategy } from './strategy';
/** @public */
export const microsoftAuthenticator = createOAuthAuthenticator({
@@ -33,7 +33,7 @@ export const microsoftAuthenticator = createOAuthAuthenticator({
const domainHint = config.getOptionalString('domainHint');
const helper = PassportOAuthAuthenticatorHelper.from(
new MicrosoftStrategy(
new ExtendedMicrosoftStrategy(
{
clientID: clientId,
clientSecret: clientSecret,
@@ -0,0 +1,107 @@
/*
* 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 { PassportProfile } from '@backstage/plugin-auth-node';
import { decodeJwt } from 'jose';
import fetch from 'node-fetch';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
export class ExtendedMicrosoftStrategy extends MicrosoftStrategy {
userProfile(
accessToken: string,
done: (err?: Error | null, profile?: PassportProfile) => void,
): void {
if (this.skipUserProfile(accessToken)) {
done(null, undefined);
return;
}
super.userProfile(
accessToken,
(err?: Error | null, profile?: PassportProfile) => {
if (!profile || profile.photos) {
done(err, profile);
return;
}
this.getProfilePhotos(accessToken).then(photos => {
profile.photos = photos;
done(err, profile);
});
},
);
}
private hasGraphReadScope(accessToken: string): boolean {
const { aud, scp } = decodeJwt(accessToken);
return (
aud === '00000003-0000-0000-c000-000000000000' &&
!!scp &&
(scp as string)
.split(' ')
.map(s => s.toLocaleLowerCase('en-US'))
.some(s =>
[
'https://graph.microsoft.com/user.read',
'https://graph.microsoft.com/user.read.all',
'user.read',
'user.read.all',
].includes(s),
)
);
}
private skipUserProfile(accessToken: string): boolean {
try {
return !this.hasGraphReadScope(accessToken);
} catch {
// If there is any error with checking the scope
// we fall back to not skipping the user profile
// which may still result in an auth failure
// e.g. due to a foreign scope.
return false;
}
}
private async getProfilePhotos(
accessToken: string,
): Promise<Array<{ value: string }> | undefined> {
return this.getCurrentUserPhoto(accessToken, '96x96').then(photo =>
photo ? [{ value: photo }] : undefined,
);
}
private async getCurrentUserPhoto(
accessToken: string,
size: string,
): Promise<string | undefined> {
try {
const res = await fetch(
`https://graph.microsoft.com/v1.0/me/photos/${size}/$value`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
const data = await res.buffer();
return `data:image/jpeg;base64,${data.toString('base64')}`;
} catch (error) {
return undefined;
}
}
}
@@ -1,25 +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.
*/
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;
}
}
+5
View File
@@ -4988,9 +4988,14 @@ __metadata:
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@types/passport-microsoft": ^1.0.0
express: ^4.18.2
jose: ^4.6.0
msw: ^1.0.0
node-fetch: ^2.6.7
passport: ^0.6.0
passport-microsoft: ^1.0.0
supertest: ^6.3.3