From 7a8ccdb2297680413f4b19d764d717a7f26bea01 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 17 Apr 2021 13:22:18 -0700 Subject: [PATCH] feat: Microsoft authentication (#1953) closes #755 --- .env.sample | 48 +++++---- app/components/AuthLogo/MicrosoftLogo.js | 44 ++++++++ app/components/AuthLogo/index.js | 33 +++++- app/scenes/Login/Provider.js | 12 +-- package.json | 1 + server/auth/providers/azure.js | 127 +++++++++++++++++++++++ server/errors.js | 6 ++ yarn.lock | 26 ++++- 8 files changed, 263 insertions(+), 34 deletions(-) create mode 100644 app/components/AuthLogo/MicrosoftLogo.js create mode 100644 server/auth/providers/azure.js 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"