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:
Greg Linklater 2021-09-03 04:50:17 +02:00 committed by GitHub
parent a3df9e868f
commit 4b2bf28531
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 180 additions and 14 deletions

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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;

View File

@ -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

View File

@ -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"