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
14
.env.sample
14
.env.sample
|
@ -76,6 +76,20 @@ 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 ––––––––––––––––
|
||||||
|
|
|
@ -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