feat: Microsoft authentication (#1953)

closes #755
This commit is contained in:
Tom Moor 2021-04-17 13:22:18 -07:00 committed by GitHub
parent b2d703bee4
commit 7a8ccdb229
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 263 additions and 34 deletions

View File

@ -27,8 +27,29 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# Third party signin credentials, at least one of EITHER Google OR Slack is
# required for a working installation or you'll have no sign-in options.
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# AUTHENTICATION
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# or Microsoft is required for a working installation or you'll have no sign-in
# options.
# To configure Slack auth, you'll need to create an Application at
# => https://api.slack.com/apps
@ -46,6 +67,12 @@ SLACK_SECRET=get_the_secret_of_above_key
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
@ -87,23 +114,6 @@ GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=

View File

@ -0,0 +1,44 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
};
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 34 34"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0002 1H33.9998C33.9998 5.8172 34.0007 10.6344 33.9988 15.4516C28.6666 15.4508 23.3334 15.4516 18.0012 15.4516C17.9993 10.6344 18.0002 5.8172 18.0002 1Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0009 17.5173C23.3333 17.5155 28.6667 17.5164 34 17.5164V33H18C18.0009 27.8388 17.9991 22.6776 18.0009 17.5173V17.5173Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1H16L15.9988 15.4516H0V1Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 17.5161C5.3332 17.5179 10.6664 17.5155 15.9996 17.5179C16.0005 22.6789 15.9996 27.839 15.9996 33H0V17.5161Z"
/>
</svg>
);
}
export default MicrosoftLogo;

View File

@ -1,19 +1,46 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import SlackLogo from "../SlackLogo";
import GoogleLogo from "./GoogleLogo";
import MicrosoftLogo from "./MicrosoftLogo";
type Props = {|
providerName: string,
size?: number,
|};
export default function AuthLogo({ providerName }: Props) {
function AuthLogo({ providerName, size = 16 }: Props) {
switch (providerName) {
case "slack":
return <SlackLogo size={16} />;
return (
<Logo>
<SlackLogo size={size} />
</Logo>
);
case "google":
return <GoogleLogo size={16} />;
return (
<Logo>
<GoogleLogo size={size} />
</Logo>
);
case "azure":
return (
<Logo>
<MicrosoftLogo size={size} />
</Logo>
);
default:
return null;
}
}
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`;
export default AuthLogo;

View File

@ -97,13 +97,11 @@ class Provider extends React.Component<Props, State> {
);
}
const icon = <AuthLogo providerName={id} />;
return (
<Wrapper key={id}>
<ButtonLarge
onClick={() => (window.location.href = authUrl)}
icon={icon ? <Logo>{icon}</Logo> : null}
icon={<AuthLogo providerName={id} />}
fullwidth
>
Continue with {name}
@ -113,14 +111,6 @@ class Provider extends React.Component<Props, State> {
}
}
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`;
const Wrapper = styled.div`
margin-bottom: 1em;
width: 100%;

View File

@ -73,6 +73,7 @@
"@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@outlinewiki/koa-passport": "^4.1.4",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@rehooks/window-scroll-position": "^1.0.1",
"@sentry/node": "^6.1.0",
"@sentry/react": "^6.1.0",

View File

@ -0,0 +1,127 @@
// @flow
import passport from "@outlinewiki/koa-passport";
import { Strategy as AzureStrategy } from "@outlinewiki/passport-azure-ad-oauth2";
import jwt from "jsonwebtoken";
import Router from "koa-router";
import accountProvisioner from "../../commands/accountProvisioner";
import env from "../../env";
import { MicrosoftGraphError } from "../../errors";
import passportMiddleware from "../../middlewares/passport";
import { StateStore } from "../../utils/passport";
const router = new Router();
const providerName = "azure";
const AZURE_CLIENT_ID = process.env.AZURE_CLIENT_ID;
const AZURE_CLIENT_SECRET = process.env.AZURE_CLIENT_SECRET;
const AZURE_RESOURCE_APP_ID = process.env.AZURE_RESOURCE_APP_ID;
const scopes = [];
export async function request(endpoint: string, accessToken: string) {
const response = await fetch(endpoint, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
return response.json();
}
export const config = {
name: "Microsoft",
enabled: !!AZURE_CLIENT_ID,
};
if (AZURE_CLIENT_ID) {
const strategy = new AzureStrategy(
{
clientID: AZURE_CLIENT_ID,
clientSecret: AZURE_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/azure.callback`,
useCommonEndpoint: true,
passReqToCallback: true,
resource: AZURE_RESOURCE_APP_ID,
store: new StateStore(),
scope: scopes,
},
async function (req, accessToken, refreshToken, params, _, done) {
try {
// see docs for what the fields in profile represent here:
// https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
const profile = jwt.decode(params.id_token);
// Load the users profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0
const profileResponse = await request(
`https://graph.microsoft.com/v1.0/me`,
accessToken
);
if (!profileResponse) {
throw new MicrosoftGraphError(
"Unable to load user profile from Microsoft Graph API"
);
}
// Load the organization profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/organization-get?view=graph-rest-1.0
const organizationResponse = await request(
`https://graph.microsoft.com/v1.0/organization`,
accessToken
);
if (!organizationResponse) {
throw new MicrosoftGraphError(
"Unable to load organization info from Microsoft Graph API"
);
}
const organization = organizationResponse.value[0];
const email = profile.email || profileResponse.mail;
if (!email) {
throw new MicrosoftGraphError(
"'email' property is required but could not be found in user profile."
);
}
const domain = email.split("@")[1];
const subdomain = domain.split(".")[0];
const teamName = organization.displayName;
const result = await accountProvisioner({
ip: req.ip,
team: {
name: teamName,
domain,
subdomain,
},
user: {
name: profile.name,
email,
avatarUrl: profile.picture,
},
authenticationProvider: {
name: providerName,
providerId: profile.tid,
},
authentication: {
providerId: profile.oid,
accessToken,
refreshToken,
scopes,
},
});
return done(null, result.user, result);
} catch (err) {
return done(err, null);
}
}
);
passport.use(strategy);
router.get("azure", passport.authenticate(providerName));
router.get("azure.callback", passportMiddleware(providerName));
}
export default router;

View File

@ -82,6 +82,12 @@ export function EmailAuthenticationRequiredError(
return httpErrors(400, message, { redirectUrl, id: "email_auth_required" });
}
export function MicrosoftGraphError(
message: string = "Microsoft Graph API did not return required fields"
) {
return httpErrors(400, message, { id: "graph_error" });
}
export function GoogleWorkspaceRequiredError(
message: string = "Google Workspace is required to authenticate"
) {

View File

@ -1728,6 +1728,13 @@
dependencies:
passport "^0.4.0"
"@outlinewiki/passport-azure-ad-oauth2@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@outlinewiki/passport-azure-ad-oauth2/-/passport-azure-ad-oauth2-0.1.0.tgz#29e8dc238c86b7e390997fc3db9accef4118a765"
integrity sha512-9tywL/KToBgolno7ZaT4/c4bRromldi/HemPB3BN3KPJyqhJG+dii3lJRsbeRF9UF+FGlm5ifmONMFLVetdZWA==
dependencies:
passport-oauth "1.0.x"
"@pmmmwh/react-refresh-webpack-plugin@^0.4.3":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz#1eec460596d200c0236bf195b078a5d1df89b766"
@ -9930,7 +9937,16 @@ passport-google-oauth2@^0.2.0:
dependencies:
passport-oauth2 "^1.1.2"
passport-oauth2@^1.1.2, passport-oauth2@^1.5.0:
passport-oauth1@1.x.x:
version "1.1.0"
resolved "https://registry.yarnpkg.com/passport-oauth1/-/passport-oauth1-1.1.0.tgz#a7de988a211f9cf4687377130ea74df32730c918"
integrity sha1-p96YiiEfnPRoc3cTDqdN8ycwyRg=
dependencies:
oauth "0.9.x"
passport-strategy "1.x.x"
utils-merge "1.x.x"
passport-oauth2@1.x.x, passport-oauth2@^1.1.2, passport-oauth2@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108"
integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==
@ -9941,6 +9957,14 @@ passport-oauth2@^1.1.2, passport-oauth2@^1.5.0:
uid2 "0.0.x"
utils-merge "1.x.x"
passport-oauth@1.0.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/passport-oauth/-/passport-oauth-1.0.0.tgz#90aff63387540f02089af28cdad39ea7f80d77df"
integrity sha1-kK/2M4dUDwIImvKM2tOep/gNd98=
dependencies:
passport-oauth1 "1.x.x"
passport-oauth2 "1.x.x"
passport-slack-oauth2@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/passport-slack-oauth2/-/passport-slack-oauth2-1.1.0.tgz#4a153b3d0d5a9e1a5041b61599d2b41a4b9486f1"