48
.env.sample
48
.env.sample
@ -27,8 +27,29 @@ REDIS_URL=redis://localhost:6479
|
|||||||
URL=http://localhost:3000
|
URL=http://localhost:3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Third party signin credentials, at least one of EITHER Google OR Slack is
|
# To support uploading of images for avatars and document attachments an
|
||||||
# required for a working installation or you'll have no sign-in options.
|
# 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
|
# To configure Slack auth, you'll need to create an Application at
|
||||||
# => https://api.slack.com/apps
|
# => https://api.slack.com/apps
|
||||||
@ -46,6 +67,12 @@ SLACK_SECRET=get_the_secret_of_above_key
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
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
|
# Optionally enable Sentry (sentry.io) to track errors and performance
|
||||||
SENTRY_DSN=
|
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
|
# 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
|
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
44
app/components/AuthLogo/MicrosoftLogo.js
Normal file
44
app/components/AuthLogo/MicrosoftLogo.js
Normal 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;
|
@ -1,19 +1,46 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
import SlackLogo from "../SlackLogo";
|
import SlackLogo from "../SlackLogo";
|
||||||
import GoogleLogo from "./GoogleLogo";
|
import GoogleLogo from "./GoogleLogo";
|
||||||
|
import MicrosoftLogo from "./MicrosoftLogo";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
providerName: string,
|
providerName: string,
|
||||||
|
size?: number,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
export default function AuthLogo({ providerName }: Props) {
|
function AuthLogo({ providerName, size = 16 }: Props) {
|
||||||
switch (providerName) {
|
switch (providerName) {
|
||||||
case "slack":
|
case "slack":
|
||||||
return <SlackLogo size={16} />;
|
return (
|
||||||
|
<Logo>
|
||||||
|
<SlackLogo size={size} />
|
||||||
|
</Logo>
|
||||||
|
);
|
||||||
case "google":
|
case "google":
|
||||||
return <GoogleLogo size={16} />;
|
return (
|
||||||
|
<Logo>
|
||||||
|
<GoogleLogo size={size} />
|
||||||
|
</Logo>
|
||||||
|
);
|
||||||
|
case "azure":
|
||||||
|
return (
|
||||||
|
<Logo>
|
||||||
|
<MicrosoftLogo size={size} />
|
||||||
|
</Logo>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Logo = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default AuthLogo;
|
||||||
|
@ -97,13 +97,11 @@ class Provider extends React.Component<Props, State> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = <AuthLogo providerName={id} />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper key={id}>
|
<Wrapper key={id}>
|
||||||
<ButtonLarge
|
<ButtonLarge
|
||||||
onClick={() => (window.location.href = authUrl)}
|
onClick={() => (window.location.href = authUrl)}
|
||||||
icon={icon ? <Logo>{icon}</Logo> : null}
|
icon={<AuthLogo providerName={id} />}
|
||||||
fullwidth
|
fullwidth
|
||||||
>
|
>
|
||||||
Continue with {name}
|
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`
|
const Wrapper = styled.div`
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -73,6 +73,7 @@
|
|||||||
"@babel/preset-flow": "^7.10.4",
|
"@babel/preset-flow": "^7.10.4",
|
||||||
"@babel/preset-react": "^7.10.4",
|
"@babel/preset-react": "^7.10.4",
|
||||||
"@outlinewiki/koa-passport": "^4.1.4",
|
"@outlinewiki/koa-passport": "^4.1.4",
|
||||||
|
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||||
"@rehooks/window-scroll-position": "^1.0.1",
|
"@rehooks/window-scroll-position": "^1.0.1",
|
||||||
"@sentry/node": "^6.1.0",
|
"@sentry/node": "^6.1.0",
|
||||||
"@sentry/react": "^6.1.0",
|
"@sentry/react": "^6.1.0",
|
||||||
|
127
server/auth/providers/azure.js
Normal file
127
server/auth/providers/azure.js
Normal 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;
|
@ -82,6 +82,12 @@ export function EmailAuthenticationRequiredError(
|
|||||||
return httpErrors(400, message, { redirectUrl, id: "email_auth_required" });
|
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(
|
export function GoogleWorkspaceRequiredError(
|
||||||
message: string = "Google Workspace is required to authenticate"
|
message: string = "Google Workspace is required to authenticate"
|
||||||
) {
|
) {
|
||||||
|
26
yarn.lock
26
yarn.lock
@ -1728,6 +1728,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
passport "^0.4.0"
|
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":
|
"@pmmmwh/react-refresh-webpack-plugin@^0.4.3":
|
||||||
version "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"
|
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:
|
dependencies:
|
||||||
passport-oauth2 "^1.1.2"
|
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"
|
version "1.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108"
|
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108"
|
||||||
integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==
|
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"
|
uid2 "0.0.x"
|
||||||
utils-merge "1.x.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:
|
passport-slack-oauth2@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/passport-slack-oauth2/-/passport-slack-oauth2-1.1.0.tgz#4a153b3d0d5a9e1a5041b61599d2b41a4b9486f1"
|
resolved "https://registry.yarnpkg.com/passport-slack-oauth2/-/passport-slack-oauth2-1.1.0.tgz#4a153b3d0d5a9e1a5041b61599d2b41a4b9486f1"
|
||||||
|
Reference in New Issue
Block a user