From 4b2bf28531a7b40c40496e9625188a62dc46f3c9 Mon Sep 17 00:00:00 2001 From: Greg Linklater Date: Fri, 3 Sep 2021 04:50:17 +0200 Subject: [PATCH] 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 --- .env.sample | 42 +++++++---- app/scenes/Login/Notices.js | 5 ++ package.json | 1 + server/auth/providers/oidc.js | 129 ++++++++++++++++++++++++++++++++++ server/errors.js | 6 ++ yarn.lock | 11 +++ 6 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 server/auth/providers/oidc.js diff --git a/.env.sample b/.env.sample index 227ac9977..98c241595 100644 --- a/.env.sample +++ b/.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:///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 diff --git a/app/scenes/Login/Notices.js b/app/scenes/Login/Notices.js index 24ad3cd56..34c910739 100644 --- a/app/scenes/Login/Notices.js +++ b/app/scenes/Login/Notices.js @@ -28,6 +28,11 @@ export default function Notices() { an allowed team domain. )} + {notice === "malformed_user_info" && ( + + We could not read the user info supplied by your identity provider. + + )} {notice === "email-auth-required" && ( Your account uses email sign-in, please sign-in with email to diff --git a/package.json b/package.json index dee5c5b22..8c01c845f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/auth/providers/oidc.js b/server/auth/providers/oidc.js new file mode 100644 index 000000000..0c9c946a8 --- /dev/null +++ b/server/auth/providers/oidc.js @@ -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; diff --git a/server/errors.js b/server/errors.js index 42145fe78..ba28608e7 100644 --- a/server/errors.js +++ b/server/errors.js @@ -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 diff --git a/yarn.lock b/yarn.lock index 39b51d6fc..97d47cc3c 100644 --- a/yarn.lock +++ b/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"