diff --git a/.env.sample b/.env.sample
index d83d1768..c0436816 100644
--- a/.env.sample
+++ b/.env.sample
@@ -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=
diff --git a/app/components/AuthLogo/MicrosoftLogo.js b/app/components/AuthLogo/MicrosoftLogo.js
new file mode 100644
index 00000000..a73c5b84
--- /dev/null
+++ b/app/components/AuthLogo/MicrosoftLogo.js
@@ -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 (
+
+ );
+}
+
+export default MicrosoftLogo;
diff --git a/app/components/AuthLogo/index.js b/app/components/AuthLogo/index.js
index 87d2de6f..c7d0d66f 100644
--- a/app/components/AuthLogo/index.js
+++ b/app/components/AuthLogo/index.js
@@ -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 ;
+ return (
+
+
+
+ );
case "google":
- return ;
+ return (
+
+
+
+ );
+ case "azure":
+ return (
+
+
+
+ );
default:
return null;
}
}
+
+const Logo = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+`;
+
+export default AuthLogo;
diff --git a/app/scenes/Login/Provider.js b/app/scenes/Login/Provider.js
index 15351cd9..27eb6e5f 100644
--- a/app/scenes/Login/Provider.js
+++ b/app/scenes/Login/Provider.js
@@ -97,13 +97,11 @@ class Provider extends React.Component {
);
}
- const icon = ;
-
return (
(window.location.href = authUrl)}
- icon={icon ? {icon} : null}
+ icon={}
fullwidth
>
Continue with {name}
@@ -113,14 +111,6 @@ class Provider extends React.Component {
}
}
-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%;
diff --git a/package.json b/package.json
index 646efb77..bfedc8ef 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/auth/providers/azure.js b/server/auth/providers/azure.js
new file mode 100644
index 00000000..2807efe8
--- /dev/null
+++ b/server/auth/providers/azure.js
@@ -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;
diff --git a/server/errors.js b/server/errors.js
index 7aab2f8d..42145fe7 100644
--- a/server/errors.js
+++ b/server/errors.js
@@ -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"
) {
diff --git a/yarn.lock b/yarn.lock
index d654a212..064ee55a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"