feat: Generic OAuth2 Authentication (#2388)
* chore: additional dependency * feat: OAuth2 authentication provider * docs: add env vars * chore: lock file * feat: add malformed user info error and notice * feat: configurable scopes * fix: explicitly enable state and disable pkce * chore: remove externally supplied username from account provisioner use * chore: remove upstream error * chore: add explicit import for fetch * chore: remove unused env var from sample * docs: openid connect claims * fix: forward fetch errors * feat: configurable team claim name * docs: move OIDC env vars together * refactor: change provider name * refactor: rename error to match provider * fix: resolve claim using lodash.get * refactor: remove OIDC_TEAM_CLAIM and hard code team name
This commit is contained in:
parent
a3df9e868f
commit
4b2bf28531
42
.env.sample
42
.env.sample
|
@ -1,6 +1,6 @@
|
|||
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
|
||||
# file to .env or set the variables in your local environment manually. For
|
||||
# development with docker this should mostly work out of the box other than
|
||||
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
|
||||
# file to .env or set the variables in your local environment manually. For
|
||||
# development with docker this should mostly work out of the box other than
|
||||
# setting the Slack keys and the SECRET_KEY.
|
||||
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
|||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
# Generate a unique random key. The format is not important but you could still use
|
||||
# Generate a unique random key. The format is not important but you could still use
|
||||
# `openssl rand -hex 32` in your terminal to produce this.
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
|
@ -31,7 +31,7 @@ PORT=3000
|
|||
|
||||
# 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
|
||||
# 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:
|
||||
|
@ -69,24 +69,38 @@ 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
|
||||
# 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_ID=
|
||||
AZURE_CLIENT_SECRET=
|
||||
AZURE_RESOURCE_APP_ID=
|
||||
|
||||
# To configure generic OIDC auth, you'll need some kind of identity provider.
|
||||
# See documentation for whichever IdP you use to acquire the following info:
|
||||
# Redirect URI is https://<URL>/auth/oidc.callback
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_AUTH_URI=
|
||||
OIDC_TOKEN_URI=
|
||||
OIDC_USERINFO_URI=
|
||||
|
||||
# Display name for OIDC authentication
|
||||
OIDC_DISPLAY_NAME=OpenID Connect
|
||||
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES=openid profile email
|
||||
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
|
@ -110,7 +124,7 @@ DEBUG=cache,presenters,events,emails,mailer,utils,http,server,processors
|
|||
# set, all domains are allowed by default when using Google OAuth to signin
|
||||
ALLOWED_DOMAINS=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
#
|
||||
|
@ -118,13 +132,13 @@ SLACK_VERIFICATION_TOKEN=your_token
|
|||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance
|
||||
SENTRY_DSN=
|
||||
|
||||
# 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
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
|
@ -138,6 +152,6 @@ SMTP_SECURE=true
|
|||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
|
|
@ -28,6 +28,11 @@ export default function Notices() {
|
|||
an allowed team domain.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "malformed_user_info" && (
|
||||
<NoticeAlert>
|
||||
We could not read the user info supplied by your identity provider.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "email-auth-required" && (
|
||||
<NoticeAlert>
|
||||
Your account uses email sign-in, please sign-in with email to
|
||||
|
|
|
@ -115,6 +115,7 @@
|
|||
"oy-vey": "^0.10.0",
|
||||
"passport": "^0.4.1",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-oauth2": "^1.6.0",
|
||||
"passport-slack-oauth2": "^1.1.0",
|
||||
"pg": "^8.5.1",
|
||||
"pg-hstore": "^2.3.3",
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
// @flow
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import fetch from "fetch-with-proxy";
|
||||
import Router from "koa-router";
|
||||
import { Strategy } from "passport-oauth2";
|
||||
import accountProvisioner from "../../commands/accountProvisioner";
|
||||
import env from "../../env";
|
||||
import { OIDCMalformedUserInfoError, AuthenticationError } from "../../errors";
|
||||
import passportMiddleware from "../../middlewares/passport";
|
||||
import { getAllowedDomains } from "../../utils/authentication";
|
||||
import { StateStore } from "../../utils/passport";
|
||||
|
||||
const router = new Router();
|
||||
const providerName = "oidc";
|
||||
const OIDC_DISPLAY_NAME = process.env.OIDC_DISPLAY_NAME || "OpenID Connect";
|
||||
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
|
||||
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
|
||||
const OIDC_AUTH_URI = process.env.OIDC_AUTH_URI;
|
||||
const OIDC_TOKEN_URI = process.env.OIDC_TOKEN_URI;
|
||||
const OIDC_USERINFO_URI = process.env.OIDC_USERINFO_URI;
|
||||
const OIDC_SCOPES = process.env.OIDC_SCOPES || "";
|
||||
const allowedDomains = getAllowedDomains();
|
||||
|
||||
export const config = {
|
||||
name: OIDC_DISPLAY_NAME,
|
||||
enabled: !!OIDC_CLIENT_ID,
|
||||
};
|
||||
|
||||
const scopes = OIDC_SCOPES.split(" ");
|
||||
|
||||
Strategy.prototype.userProfile = async function (accessToken, done) {
|
||||
try {
|
||||
const response = await fetch(OIDC_USERINFO_URI, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
return done(null, await response.json());
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (OIDC_CLIENT_ID) {
|
||||
passport.use(
|
||||
providerName,
|
||||
new Strategy(
|
||||
{
|
||||
authorizationURL: OIDC_AUTH_URI,
|
||||
tokenURL: OIDC_TOKEN_URI,
|
||||
clientID: OIDC_CLIENT_ID,
|
||||
clientSecret: OIDC_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/${providerName}.callback`,
|
||||
passReqToCallback: true,
|
||||
scope: OIDC_SCOPES,
|
||||
store: new StateStore(),
|
||||
state: true,
|
||||
pkce: false,
|
||||
},
|
||||
|
||||
// OpenID Connect standard profile claims can be found in the official
|
||||
// specification.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
|
||||
// Non-standard claims may be configured by individual identity providers.
|
||||
// Any claim supplied in response to the userinfo request will be
|
||||
// available on the `profile` parameter
|
||||
async function (req, accessToken, refreshToken, profile, done) {
|
||||
try {
|
||||
const parts = profile.email.split("@");
|
||||
const domain = parts.length && parts[1];
|
||||
|
||||
if (!domain) {
|
||||
throw new OIDCMalformedUserInfoError();
|
||||
}
|
||||
|
||||
if (allowedDomains.length && !allowedDomains.includes(domain)) {
|
||||
throw new AuthenticationError(
|
||||
`Domain ${domain} is not on the whitelist`
|
||||
);
|
||||
}
|
||||
|
||||
const subdomain = domain.split(".")[0];
|
||||
|
||||
const result = await accountProvisioner({
|
||||
ip: req.ip,
|
||||
team: {
|
||||
// https://github.com/outline/outline/pull/2388#discussion_r681120223
|
||||
name: "Wiki",
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.picture,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: providerName,
|
||||
providerId: domain,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.sub,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
|
||||
return done(null, result.user, result);
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get(providerName, passport.authenticate(providerName));
|
||||
|
||||
router.get(`${providerName}.callback`, passportMiddleware(providerName));
|
||||
}
|
||||
|
||||
export default router;
|
|
@ -100,6 +100,12 @@ export function GoogleWorkspaceInvalidError(
|
|||
return httpErrors(400, message, { id: "hd_not_allowed" });
|
||||
}
|
||||
|
||||
export function OIDCMalformedUserInfoError(
|
||||
message: string = "User profile information malformed"
|
||||
) {
|
||||
return httpErrors(400, message, { id: "malformed_user_info" });
|
||||
}
|
||||
|
||||
export function AuthenticationProviderDisabledError(
|
||||
message: string = "Authentication method has been disabled by an admin",
|
||||
redirectUrl: string = env.URL
|
||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -10092,6 +10092,17 @@ passport-oauth2@1.x.x, passport-oauth2@^1.1.2, passport-oauth2@^1.5.0:
|
|||
uid2 "0.0.x"
|
||||
utils-merge "1.x.x"
|
||||
|
||||
passport-oauth2@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.6.0.tgz#5f599735e0ea40ea3027643785f81a3a9b4feb50"
|
||||
integrity sha512-emXPLqLcVEcLFR/QvQXZcwLmfK8e9CqvMgmOFJxcNT3okSFMtUbRRKpY20x5euD+01uHsjjCa07DYboEeLXYiw==
|
||||
dependencies:
|
||||
base64url "3.x.x"
|
||||
oauth "0.9.x"
|
||||
passport-strategy "1.x.x"
|
||||
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"
|
||||
|
|
Reference in New Issue