add microsoft azure auth provider + add to example app
This commit is contained in:
@@ -74,6 +74,41 @@ export AUTH_AUTH0_CLIENT_ID=x
|
||||
export AUTH_AUTH0_CLIENT_SECRET=x
|
||||
```
|
||||
|
||||
### Microsoft
|
||||
|
||||
#### Creating an Azure AD App Registration
|
||||
|
||||
An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API.
|
||||
Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) to create a new one.
|
||||
|
||||
- Click on the `New Registration` button.
|
||||
- Give the app a name. e.g. `backstage-dev`
|
||||
- Select `Accounts in this organizational directory only` under supported account types.
|
||||
- Enter the callback URL for your backstage backend instance:
|
||||
- For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame`
|
||||
- For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame`
|
||||
- Click `Register`.
|
||||
|
||||
We also need to generate a client secret so Backstage can authenticate as this app.
|
||||
|
||||
- Click on the `Certificates & secrets` menu item.
|
||||
- Under `Client secrets`, click on `New client secret`.
|
||||
- Add a description for the new secret. e.g. `auth-backend-plugin`
|
||||
- Select an expiry time; `1 Year`, `2 Years` or `Never`.
|
||||
- Click `Add`.
|
||||
|
||||
The secret value will then be displayed on the screen. **You will not be able to retrieve it again after leaving the page**.
|
||||
|
||||
#### Starting the Auth Backend
|
||||
|
||||
```bash
|
||||
cd packages/backend
|
||||
export AUTH_AZURE_CLIENT_ID=x
|
||||
export AUTH_AZURE_CLIENT_SECRET=x
|
||||
export AUTH_AZURE_TENANT_ID=x
|
||||
yarn start
|
||||
```
|
||||
|
||||
### SAML
|
||||
|
||||
To try out SAML, you can use the mock identity provider:
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
"got": "^11.5.2",
|
||||
"helmet": "^4.0.0",
|
||||
"jose": "^1.27.1",
|
||||
"jwt-decode": "2.2.0",
|
||||
@@ -40,6 +41,7 @@
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-gitlab2": "^5.0.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-microsoft": "^0.1.0",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"passport-okta-oauth": "^0.0.1",
|
||||
"passport-saml": "^1.3.3",
|
||||
@@ -55,6 +57,7 @@
|
||||
"@types/passport": "^1.0.3",
|
||||
"@types/passport-github2": "^1.2.4",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"@types/passport-microsoft": "^0.0.0",
|
||||
"@types/passport-saml": "^1.1.2",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createOAuth2Provider } from './oauth2';
|
||||
import { createOktaProvider } from './okta';
|
||||
import { createSamlProvider } from './saml';
|
||||
import { createAuth0Provider } from './auth0';
|
||||
import { createMicrosoftProvider } from './microsoft';
|
||||
import {
|
||||
AuthProviderConfig,
|
||||
AuthProviderFactory,
|
||||
@@ -42,6 +43,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
saml: createSamlProvider,
|
||||
okta: createOktaProvider,
|
||||
auth0: createAuth0Provider,
|
||||
microsoft: createMicrosoftProvider,
|
||||
oauth2: createOAuth2Provider,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { createMicrosoftProvider } from './provider';
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 express from 'express';
|
||||
import passport from 'passport';
|
||||
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
|
||||
|
||||
import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
executeFetchUserProfileStrategy,
|
||||
} from '../../lib/PassportStrategyHelper';
|
||||
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
RedirectInfo,
|
||||
AuthProviderConfig,
|
||||
OAuthProviderOptions,
|
||||
OAuthResponse,
|
||||
PassportDoneCallback,
|
||||
} from '../types';
|
||||
|
||||
import { OAuthProvider } from '../../lib/OAuthProvider';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
import got from 'got';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export type MicrosoftAuthProviderOptions = OAuthProviderOptions & {
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
};
|
||||
|
||||
export class MicrosoftAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly _strategy: MicrosoftStrategy;
|
||||
|
||||
static transformAuthResponse(
|
||||
accessToken: string,
|
||||
params: any,
|
||||
rawProfile: any,
|
||||
photoURL?: any,
|
||||
): OAuthResponse {
|
||||
let passportProfile: passport.Profile = rawProfile;
|
||||
if (photoURL) {
|
||||
passportProfile = {
|
||||
...passportProfile,
|
||||
photos: [{ value: photoURL }],
|
||||
};
|
||||
}
|
||||
|
||||
const profile = makeProfileInfo(passportProfile, params.id_token);
|
||||
const providerInfo = {
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
};
|
||||
|
||||
return {
|
||||
providerInfo,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(options: MicrosoftAuthProviderOptions) {
|
||||
this._strategy = new MicrosoftStrategy(
|
||||
{
|
||||
clientID: options.clientId,
|
||||
clientSecret: options.clientSecret,
|
||||
callbackURL: options.callbackUrl,
|
||||
authorizationURL: options.authorizationUrl,
|
||||
tokenURL: options.tokenUrl,
|
||||
passReqToCallback: false as true,
|
||||
},
|
||||
(
|
||||
accessToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
rawProfile: passport.Profile,
|
||||
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
|
||||
) => {
|
||||
got
|
||||
.get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', {
|
||||
encoding: 'binary',
|
||||
responseType: 'buffer',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
.then(photoData => {
|
||||
const photoURL = `data:image/jpeg;base64,${Buffer.from(
|
||||
photoData.body,
|
||||
).toString('base64')}`;
|
||||
const authResponse = MicrosoftAuthProvider.transformAuthResponse(
|
||||
accessToken,
|
||||
params,
|
||||
rawProfile,
|
||||
photoURL,
|
||||
);
|
||||
done(undefined, authResponse, { refreshToken });
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(
|
||||
`Error retrieving user photo from Microsoft Graph API: ${error}`,
|
||||
);
|
||||
const authResponse = MicrosoftAuthProvider.transformAuthResponse(
|
||||
accessToken,
|
||||
params,
|
||||
rawProfile,
|
||||
);
|
||||
done(undefined, authResponse, { refreshToken });
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async start(
|
||||
req: express.Request,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo> {
|
||||
return await executeRedirectStrategy(req, this._strategy, options);
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
const { response, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResponse,
|
||||
PrivateInfo
|
||||
>(req, this._strategy);
|
||||
|
||||
return {
|
||||
response: await this.populateIdentity(response),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
refreshToken,
|
||||
scope,
|
||||
);
|
||||
|
||||
const profile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
params.id_token,
|
||||
);
|
||||
const photo = await this.getUserPhoto(accessToken);
|
||||
if (photo) {
|
||||
profile.picture = photo;
|
||||
}
|
||||
|
||||
return this.populateIdentity({
|
||||
providerInfo: {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
},
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
private getUserPhoto(accessToken: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
got
|
||||
.get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', {
|
||||
encoding: 'binary',
|
||||
responseType: 'buffer',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
.then(photoData => {
|
||||
const photoURL = `data:image/jpeg;base64,${Buffer.from(
|
||||
photoData.body,
|
||||
).toString('base64')}`;
|
||||
resolve(photoURL);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(
|
||||
`Error retrieving user photo from Microsoft Graph API: ${error}`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async populateIdentity(
|
||||
response: OAuthResponse,
|
||||
): Promise<OAuthResponse> {
|
||||
const { profile } = response;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Microsoft profile contained no email');
|
||||
}
|
||||
|
||||
// Like Google implementation, setting this to local part of email for now
|
||||
const id = profile.email.split('@')[0];
|
||||
|
||||
return { ...response, backstageIdentity: { id } };
|
||||
}
|
||||
}
|
||||
|
||||
export function createMicrosoftProvider(
|
||||
config: AuthProviderConfig,
|
||||
_: string,
|
||||
envConfig: Config,
|
||||
_logger: Logger,
|
||||
tokenIssuer: TokenIssuer,
|
||||
) {
|
||||
const providerId = 'microsoft';
|
||||
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const tenantID = envConfig.getString('tenantId');
|
||||
|
||||
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
|
||||
const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`;
|
||||
const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`;
|
||||
|
||||
const provider = new MicrosoftAuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
authorizationUrl,
|
||||
tokenUrl,
|
||||
});
|
||||
|
||||
return OAuthProvider.fromConfig(config, provider, {
|
||||
disableRefresh: false,
|
||||
providerId,
|
||||
tokenIssuer,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user