Merge pull request #20706 from Bonial-International-GmbH/pjungermann/auth-microsoft-photo
fix missing profile photo and access token for foreign scopes at Microsoft auth provider
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user