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