feat: allow external SSO methods to log into teams as long as emails match (#3813)

* wip

* wip

* fix comments

* better separation of conerns

* fix up tests

* fix semantics

* fixup tsc

* fix some tests

* the old semantics were easier to use

* add db:reset to scripts

* explicitly throw for unauthorized external authorization

* fix minor bug

* add additional tests for user creator and team creator

* yank the email matching logic out of teamcreator

* renaming

* fix type and test errors

* adds test to ensure that accountProvisioner works with email matching

* remove only

* fix comments

* recreate changes to allow self hosted to make teams
This commit is contained in:
Nan Yu
2022-07-24 07:55:30 -04:00
committed by GitHub
parent 24170e8684
commit 870d9ed41e
11 changed files with 322 additions and 165 deletions

View File

@@ -4,6 +4,7 @@ import jwt from "jsonwebtoken";
import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner, {
AccountProvisionerResult,
} from "@server/commands/accountProvisioner";
@@ -95,12 +96,13 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const team = await getTeamFromContext(ctx);
const domain = email.split("@")[1];
const subdomain = domain.split(".")[0];
const subdomain = slugifyDomain(domain);
const teamName = organization.displayName;
const result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
teamId: team?.id,
name: teamName,
domain,
subdomain,

View File

@@ -11,7 +11,6 @@ import accountProvisioner, {
import env from "@server/env";
import {
GmailAccountCreationError,
InviteRequiredError,
TeamDomainRequiredError,
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
@@ -19,7 +18,7 @@ import { User } from "@server/models";
import { StateStore, getTeamFromContext } from "@server/utils/passport";
const router = new Router();
const providerName = "google";
const GOOGLE = "google";
const scopes = [
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
@@ -34,7 +33,7 @@ type GoogleProfile = Profile & {
email: string;
picture: string;
_json: {
hd: string;
hd?: string;
};
};
@@ -63,87 +62,66 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
) => void
) {
try {
let result;
// "domain" is the Google Workspaces domain
const domain = profile._json.hd;
const team = await getTeamFromContext(ctx);
// Existence of domain means this is a Google Workspaces account
// so we'll attempt to provision an account (team and user)
if (domain) {
// remove the TLD and form a subdomain from the remaining
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
const subdomain = slugifyDomain(domain);
const teamName = capitalize(subdomain);
// Request a larger size profile picture than the default by tweaking
// the query parameter.
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
// if a team can be inferred, we assume the user is only interested in signing into
// that team in particular; otherwise, we will do a best effort at finding their account
// or provisioning a new one (within AccountProvisioner)
result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
name: teamName,
domain,
subdomain,
},
user: {
email: profile.email,
name: profile.displayName,
avatarUrl,
},
authenticationProvider: {
name: providerName,
providerId: domain,
},
authentication: {
providerId: profile.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
} else {
// No domain means it's a personal Gmail account
// We only allow sign-in to existing user accounts with these
if (!team) {
// No team usually means this is the apex domain
// Throw different errors depending on whether we think the user is
// trying to create a new account, or log-in to an existing one
const userExists = await User.count({
where: { email: profile.email.toLowerCase() },
});
if (!userExists) {
throw GmailAccountCreationError();
}
throw TeamDomainRequiredError();
}
const user = await User.findOne({
where: { teamId: team.id, email: profile.email.toLowerCase() },
// No profile domain means personal gmail account
// No team implies the request came from the apex domain
// This combination is always an error
if (!domain && !team) {
const userExists = await User.count({
where: { email: profile.email.toLowerCase() },
});
if (!user) {
throw InviteRequiredError();
// Users cannot create a team with personal gmail accounts
if (!userExists) {
throw GmailAccountCreationError();
}
result = {
user,
team,
isNewUser: false,
isNewTeam: false,
};
// To log-in with a personal account, users must specify a team subdomain
throw TeamDomainRequiredError();
}
// remove the TLD and form a subdomain from the remaining
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
const subdomain = domain ? slugifyDomain(domain) : "";
const teamName = capitalize(subdomain);
// Request a larger size profile picture than the default by tweaking
// the query parameter.
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
// if a team can be inferred, we assume the user is only interested in signing into
// that team in particular; otherwise, we will do a best effort at finding their account
// or provisioning a new one (within AccountProvisioner)
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
name: teamName,
domain,
subdomain,
},
user: {
email: profile.email,
name: profile.displayName,
avatarUrl,
},
authenticationProvider: {
name: GOOGLE,
providerId: domain ?? "",
},
authentication: {
providerId: profile.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
return done(null, result.user, result);
} catch (err) {
return done(err, null);
@@ -154,13 +132,13 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
router.get(
"google",
passport.authenticate(providerName, {
passport.authenticate(GOOGLE, {
accessType: "offline",
prompt: "select_account consent",
})
);
router.get("google.callback", passportMiddleware(providerName));
router.get("google.callback", passportMiddleware(GOOGLE));
}
export default router;

View File

@@ -97,7 +97,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
const result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
teamId: team?.id,
// https://github.com/outline/outline/pull/2388#discussion_r681120223
name: "Wiki",
domain,

View File

@@ -79,7 +79,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const result = await accountProvisioner({
ip: ctx.ip,
team: {
id: team?.id,
teamId: team?.id,
name: profile.team.name,
subdomain: profile.team.domain,
avatarUrl: profile.team.image_230,