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
|
# 👋 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
|
# 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
|
# development with docker this should mostly work out of the box other than
|
||||||
# setting the Slack keys and the SECRET_KEY.
|
# setting the Slack keys and the SECRET_KEY.
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
# in your terminal to generate a random value.
|
# in your terminal to generate a random value.
|
||||||
SECRET_KEY=generate_a_new_key
|
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.
|
# `openssl rand -hex 32` in your terminal to produce this.
|
||||||
UTILS_SECRET=generate_a_new_key
|
UTILS_SECRET=generate_a_new_key
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ PORT=3000
|
||||||
|
|
||||||
# To support uploading of images for avatars and document attachments an
|
# To support uploading of images for avatars and document attachments an
|
||||||
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
|
# 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.
|
# minio (https://github.com/minio/minio) can be used.
|
||||||
|
|
||||||
# A more detailed guide on setting up S3 is available here:
|
# 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_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
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:
|
# the guide for details on setting up your Azure App:
|
||||||
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
|
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
|
||||||
AZURE_CLIENT_ID=
|
AZURE_CLIENT_ID=
|
||||||
AZURE_CLIENT_SECRET=
|
AZURE_CLIENT_SECRET=
|
||||||
AZURE_RESOURCE_APP_ID=
|
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 ––––––––––––––––
|
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||||
|
|
||||||
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
# 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
|
# 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
|
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||||
# should be set to the same as URL.
|
# should be set to the same as URL.
|
||||||
CDN_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.
|
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||||
FORCE_HTTPS=true
|
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
|
# set, all domains are allowed by default when using Google OAuth to signin
|
||||||
ALLOWED_DOMAINS=
|
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
|
# following configs are also needed, some more details
|
||||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||||
#
|
#
|
||||||
|
@ -118,13 +132,13 @@ SLACK_VERIFICATION_TOKEN=your_token
|
||||||
SLACK_APP_ID=A0XXXXXXX
|
SLACK_APP_ID=A0XXXXXXX
|
||||||
SLACK_MESSAGE_ACTIONS=true
|
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=
|
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 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=
|
||||||
SMTP_PORT=
|
SMTP_PORT=
|
||||||
|
@ -138,6 +152,6 @@ SMTP_SECURE=true
|
||||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||||
# TEAM_LOGO=https://example.com/images/logo.png
|
# 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.
|
# available language codes and their rough percentage translated.
|
||||||
DEFAULT_LANGUAGE=en_US
|
DEFAULT_LANGUAGE=en_US
|
||||||
|
|
|
@ -28,6 +28,11 @@ export default function Notices() {
|
||||||
an allowed team domain.
|
an allowed team domain.
|
||||||
</NoticeAlert>
|
</NoticeAlert>
|
||||||
)}
|
)}
|
||||||
|
{notice === "malformed_user_info" && (
|
||||||
|
<NoticeAlert>
|
||||||
|
We could not read the user info supplied by your identity provider.
|
||||||
|
</NoticeAlert>
|
||||||
|
)}
|
||||||
{notice === "email-auth-required" && (
|
{notice === "email-auth-required" && (
|
||||||
<NoticeAlert>
|
<NoticeAlert>
|
||||||
Your account uses email sign-in, please sign-in with email to
|
Your account uses email sign-in, please sign-in with email to
|
||||||
|
|
|
@ -115,6 +115,7 @@
|
||||||
"oy-vey": "^0.10.0",
|
"oy-vey": "^0.10.0",
|
||||||
"passport": "^0.4.1",
|
"passport": "^0.4.1",
|
||||||
"passport-google-oauth2": "^0.2.0",
|
"passport-google-oauth2": "^0.2.0",
|
||||||
|
"passport-oauth2": "^1.6.0",
|
||||||
"passport-slack-oauth2": "^1.1.0",
|
"passport-slack-oauth2": "^1.1.0",
|
||||||
"pg": "^8.5.1",
|
"pg": "^8.5.1",
|
||||||
"pg-hstore": "^2.3.3",
|
"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" });
|
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(
|
export function AuthenticationProviderDisabledError(
|
||||||
message: string = "Authentication method has been disabled by an admin",
|
message: string = "Authentication method has been disabled by an admin",
|
||||||
redirectUrl: string = env.URL
|
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"
|
uid2 "0.0.x"
|
||||||
utils-merge "1.x.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:
|
passport-oauth@1.0.x:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/passport-oauth/-/passport-oauth-1.0.0.tgz#90aff63387540f02089af28cdad39ea7f80d77df"
|
resolved "https://registry.yarnpkg.com/passport-oauth/-/passport-oauth-1.0.0.tgz#90aff63387540f02089af28cdad39ea7f80d77df"
|
||||||
|
|
Reference in New Issue