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

@@ -22,6 +22,7 @@
"db:create-migration": "sequelize migration:create", "db:create-migration": "sequelize migration:create",
"db:migrate": "sequelize db:migrate", "db:migrate": "sequelize db:migrate",
"db:rollback": "sequelize db:migrate:undo", "db:rollback": "sequelize db:migrate:undo",
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild", "upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "yarn test:app && yarn test:server", "test": "yarn test:app && yarn test:server",
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit", "test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",

View File

@@ -108,6 +108,44 @@ describe("accountProvisioner", () => {
spy.mockRestore(); spy.mockRestore();
}); });
it("should allow authentication by email matching", async () => {
const existingTeam = await buildTeam();
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
const userWithoutAuth = await buildUser({
email: "email@example.com",
teamId: existingTeam.id,
authentications: [],
});
const { user, isNewUser, isNewTeam } = await accountProvisioner({
ip,
user: {
name: userWithoutAuth.name,
email: "email@example.com",
avatarUrl: userWithoutAuth.avatarUrl,
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: "example",
},
authenticationProvider: {
name: authenticationProvider.name,
providerId: authenticationProvider.providerId,
},
authentication: {
providerId: "anything",
accessToken: "123",
scopes: ["read"],
},
});
expect(user.id).toEqual(userWithoutAuth.id);
expect(isNewTeam).toEqual(false);
expect(isNewUser).toEqual(false);
expect(user.authentications.length).toEqual(0);
});
it("should throw an error when authentication provider is disabled", async () => { it("should throw an error when authentication provider is disabled", async () => {
const existingTeam = await buildTeam(); const existingTeam = await buildTeam();
const providers = await existingTeam.$get("authenticationProviders"); const providers = await existingTeam.$get("authenticationProviders");

View File

@@ -8,9 +8,9 @@ import {
AuthenticationProviderDisabledError, AuthenticationProviderDisabledError,
} from "@server/errors"; } from "@server/errors";
import { APM } from "@server/logging/tracing"; import { APM } from "@server/logging/tracing";
import { Collection, Team, User } from "@server/models"; import { AuthenticationProvider, Collection, Team, User } from "@server/models";
import teamCreator from "./teamCreator"; import teamProvisioner from "./teamProvisioner";
import userCreator from "./userCreator"; import userProvisioner from "./userProvisioner";
type Props = { type Props = {
ip: string; ip: string;
@@ -21,7 +21,7 @@ type Props = {
username?: string; username?: string;
}; };
team: { team: {
id?: string; teamId?: string;
name: string; name: string;
domain?: string; domain?: string;
subdomain: string; subdomain: string;
@@ -55,15 +55,45 @@ async function accountProvisioner({
authentication: authenticationParams, authentication: authenticationParams,
}: Props): Promise<AccountProvisionerResult> { }: Props): Promise<AccountProvisionerResult> {
let result; let result;
let emailMatchOnly;
try { try {
result = await teamCreator({ result = await teamProvisioner({
...teamParams, ...teamParams,
authenticationProvider: authenticationProviderParams, authenticationProvider: authenticationProviderParams,
ip, ip,
}); });
} catch (err) { } catch (err) {
throw InvalidAuthenticationError(err.message); // The account could not be provisioned for the provided teamId
// check to see if we can try authentication using email matching only
if (err.id === "invalid_authentication") {
const authenticationProvider = await AuthenticationProvider.findOne({
where: {
name: authenticationProviderParams.name, // example: "google"
teamId: teamParams.teamId,
},
include: [
{
model: Team,
as: "team",
required: true,
},
],
});
if (authenticationProvider) {
emailMatchOnly = true;
result = {
authenticationProvider,
team: authenticationProvider.team,
isNewTeam: false,
};
}
}
if (!result) {
throw InvalidAuthenticationError(err.message);
}
} }
invariant(result, "Team creator result must exist"); invariant(result, "Team creator result must exist");
@@ -74,20 +104,21 @@ async function accountProvisioner({
} }
try { try {
const result = await userCreator({ const result = await userProvisioner({
name: userParams.name, name: userParams.name,
email: userParams.email, email: userParams.email,
username: userParams.username, username: userParams.username,
isAdmin: isNewTeam || undefined, isAdmin: isNewTeam || undefined,
avatarUrl: userParams.avatarUrl, avatarUrl: userParams.avatarUrl,
teamId: team.id, teamId: team.id,
emailMatchOnly,
ip, ip,
authentication: { authentication: {
authenticationProviderId: authenticationProvider.id,
...authenticationParams, ...authenticationParams,
expiresAt: authenticationParams.expiresIn expiresAt: authenticationParams.expiresIn
? new Date(Date.now() + authenticationParams.expiresIn * 1000) ? new Date(Date.now() + authenticationParams.expiresIn * 1000)
: undefined, : undefined,
authenticationProviderId: authenticationProvider.id,
}, },
}); });
const { isNewUser, user } = result; const { isNewUser, user } = result;

View File

@@ -2,16 +2,16 @@ import env from "@server/env";
import TeamDomain from "@server/models/TeamDomain"; import TeamDomain from "@server/models/TeamDomain";
import { buildTeam, buildUser } from "@server/test/factories"; import { buildTeam, buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support"; import { flushdb } from "@server/test/support";
import teamCreator from "./teamCreator"; import teamProvisioner from "./teamProvisioner";
beforeEach(() => flushdb()); beforeEach(() => flushdb());
describe("teamCreator", () => { describe("teamProvisioner", () => {
const ip = "127.0.0.1"; const ip = "127.0.0.1";
it("should create team and authentication provider", async () => { it("should create team and authentication provider", async () => {
env.DEPLOYMENT = "hosted"; env.DEPLOYMENT = "hosted";
const result = await teamCreator({ const result = await teamProvisioner({
name: "Test team", name: "Test team",
subdomain: "example", subdomain: "example",
avatarUrl: "http://example.com/logo.png", avatarUrl: "http://example.com/logo.png",
@@ -35,7 +35,7 @@ describe("teamCreator", () => {
await buildTeam({ await buildTeam({
subdomain: "myteam", subdomain: "myteam",
}); });
const result = await teamCreator({ const result = await teamProvisioner({
name: "Test team", name: "Test team",
subdomain: "myteam", subdomain: "myteam",
avatarUrl: "http://example.com/logo.png", avatarUrl: "http://example.com/logo.png",
@@ -58,7 +58,7 @@ describe("teamCreator", () => {
await buildTeam({ await buildTeam({
subdomain: "myteam1", subdomain: "myteam1",
}); });
const result = await teamCreator({ const result = await teamProvisioner({
name: "Test team", name: "Test team",
subdomain: "myteam", subdomain: "myteam",
avatarUrl: "http://example.com/logo.png", avatarUrl: "http://example.com/logo.png",
@@ -72,10 +72,63 @@ describe("teamCreator", () => {
expect(result.team.subdomain).toEqual("myteam2"); expect(result.team.subdomain).toEqual("myteam2");
}); });
it("should return existing team", async () => {
env.DEPLOYMENT = "hosted";
const authenticationProvider = {
name: "google",
providerId: "example.com",
};
const existing = await buildTeam({
subdomain: "example",
authenticationProviders: [authenticationProvider],
});
const result = await teamProvisioner({
name: "Updated name",
subdomain: "example",
authenticationProvider,
ip,
});
const { team, isNewTeam } = result;
expect(team.id).toEqual(existing.id);
expect(team.name).toEqual(existing.name);
expect(team.subdomain).toEqual("example");
expect(isNewTeam).toEqual(false);
});
it("should error on mismatched team and authentication provider", async () => {
env.DEPLOYMENT = "hosted";
const exampleTeam = await buildTeam({
subdomain: "example",
authenticationProviders: [
{
name: "google",
providerId: "example.com",
},
],
});
let error;
try {
await teamProvisioner({
teamId: exampleTeam.id,
name: "name",
subdomain: "other",
authenticationProvider: {
name: "google",
providerId: "other.com",
},
ip,
});
} catch (e) {
error = e;
}
expect(error.id).toEqual("invalid_authentication");
});
describe("self hosted", () => { describe("self hosted", () => {
it("should allow creating first team", async () => { it("should allow creating first team", async () => {
env.DEPLOYMENT = undefined; env.DEPLOYMENT = undefined;
const { team, isNewTeam } = await teamCreator({ const { team, isNewTeam } = await teamProvisioner({
name: "Test team", name: "Test team",
subdomain: "example", subdomain: "example",
avatarUrl: "http://example.com/logo.png", avatarUrl: "http://example.com/logo.png",
@@ -96,7 +149,7 @@ describe("teamCreator", () => {
let error; let error;
try { try {
await teamCreator({ await teamProvisioner({
name: "Test team", name: "Test team",
subdomain: "example", subdomain: "example",
avatarUrl: "http://example.com/logo.png", avatarUrl: "http://example.com/logo.png",
@@ -124,7 +177,7 @@ describe("teamCreator", () => {
name: "allowed-domain.com", name: "allowed-domain.com",
createdById: user.id, createdById: user.id,
}); });
const result = await teamCreator({ const result = await teamProvisioner({
name: "Updated name", name: "Updated name",
subdomain: "example", subdomain: "example",
domain: "allowed-domain.com", domain: "allowed-domain.com",
@@ -158,7 +211,7 @@ describe("teamCreator", () => {
let error; let error;
try { try {
await teamCreator({ await teamProvisioner({
name: "Updated name", name: "Updated name",
subdomain: "example", subdomain: "example",
domain: "other-domain.com", domain: "other-domain.com",
@@ -175,7 +228,7 @@ describe("teamCreator", () => {
expect(error).toBeTruthy(); expect(error).toBeTruthy();
}); });
it("should return exising team", async () => { it("should return existing team", async () => {
env.DEPLOYMENT = undefined; env.DEPLOYMENT = undefined;
const authenticationProvider = { const authenticationProvider = {
name: "google", name: "google",
@@ -185,7 +238,7 @@ describe("teamCreator", () => {
subdomain: "example", subdomain: "example",
authenticationProviders: [authenticationProvider], authenticationProviders: [authenticationProvider],
}); });
const result = await teamCreator({ const result = await teamProvisioner({
name: "Updated name", name: "Updated name",
subdomain: "example", subdomain: "example",
authenticationProvider, authenticationProvider,

View File

@@ -1,8 +1,8 @@
import { sequelize } from "@server/database/sequelize"; import { sequelize } from "@server/database/sequelize";
import env from "@server/env"; import env from "@server/env";
import { import {
InvalidAuthenticationError,
DomainNotAllowedError, DomainNotAllowedError,
InvalidAuthenticationError,
MaximumTeamsError, MaximumTeamsError,
} from "@server/errors"; } from "@server/errors";
import Logger from "@server/logging/Logger"; import Logger from "@server/logging/Logger";
@@ -10,14 +10,14 @@ import { APM } from "@server/logging/tracing";
import { Team, AuthenticationProvider, Event } from "@server/models"; import { Team, AuthenticationProvider, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars"; import { generateAvatarUrl } from "@server/utils/avatars";
type TeamCreatorResult = { type TeamProvisionerResult = {
team: Team; team: Team;
authenticationProvider: AuthenticationProvider; authenticationProvider: AuthenticationProvider;
isNewTeam: boolean; isNewTeam: boolean;
}; };
type Props = { type Props = {
id?: string; teamId?: string;
name: string; name: string;
domain?: string; domain?: string;
subdomain: string; subdomain: string;
@@ -29,18 +29,18 @@ type Props = {
ip: string; ip: string;
}; };
async function teamCreator({ async function teamProvisioner({
id, teamId,
name, name,
domain, domain,
subdomain, subdomain,
avatarUrl, avatarUrl,
authenticationProvider, authenticationProvider,
ip, ip,
}: Props): Promise<TeamCreatorResult> { }: Props): Promise<TeamProvisionerResult> {
let authP = await AuthenticationProvider.findOne({ let authP = await AuthenticationProvider.findOne({
where: id where: teamId
? { ...authenticationProvider, teamId: id } ? { ...authenticationProvider, teamId }
: authenticationProvider, : authenticationProvider,
include: [ include: [
{ {
@@ -59,11 +59,9 @@ async function teamCreator({
team: authP.team, team: authP.team,
isNewTeam: false, isNewTeam: false,
}; };
} } else if (teamId) {
// A team id was provided but no auth provider was found matching those credentials // The user is attempting to log into a team with an unfamiliar SSO provider
// The user is attempting to log into a team with an incorrect SSO - fail the login throw InvalidAuthenticationError();
else if (id) {
throw InvalidAuthenticationError("incorrect authentication credentials");
} }
// This team has never been seen before, if self hosted the logic is different // This team has never been seen before, if self hosted the logic is different
@@ -176,5 +174,5 @@ async function provisionSubdomain(team: Team, requestedSubdomain: string) {
export default APM.traceFunction({ export default APM.traceFunction({
serviceName: "command", serviceName: "command",
spanName: "teamCreator", spanName: "teamProvisioner",
})(teamCreator); })(teamProvisioner);

View File

@@ -2,20 +2,20 @@ import { v4 as uuidv4 } from "uuid";
import { TeamDomain } from "@server/models"; import { TeamDomain } from "@server/models";
import { buildUser, buildTeam, buildInvite } from "@server/test/factories"; import { buildUser, buildTeam, buildInvite } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support"; import { flushdb, seed } from "@server/test/support";
import userCreator from "./userCreator"; import userProvisioner from "./userProvisioner";
beforeEach(() => flushdb()); beforeEach(() => flushdb());
describe("userCreator", () => { describe("userProvisioner", () => {
const ip = "127.0.0.1"; const ip = "127.0.0.1";
it("should update exising user and authentication", async () => { it("should update existing user and authentication", async () => {
const existing = await buildUser(); const existing = await buildUser();
const authentications = await existing.$get("authentications"); const authentications = await existing.$get("authentications");
const existingAuth = authentications[0]; const existingAuth = authentications[0];
const newEmail = "test@example.com"; const newEmail = "test@example.com";
const newUsername = "tname"; const newUsername = "tname";
const result = await userCreator({ const result = await userProvisioner({
name: existing.name, name: existing.name,
email: newEmail, email: newEmail,
username: newUsername, username: newUsername,
@@ -30,9 +30,10 @@ describe("userCreator", () => {
}, },
}); });
const { user, authentication, isNewUser } = result; const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123"); expect(authentication).toBeDefined();
expect(authentication.scopes.length).toEqual(1); expect(authentication?.accessToken).toEqual("123");
expect(authentication.scopes[0]).toEqual("read"); expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual(newEmail); expect(user.email).toEqual(newEmail);
expect(user.username).toEqual(newUsername); expect(user.username).toEqual(newUsername);
expect(isNewUser).toEqual(false); expect(isNewUser).toEqual(false);
@@ -50,7 +51,7 @@ describe("userCreator", () => {
authentications: [], authentications: [],
}); });
const result = await userCreator({ const result = await userProvisioner({
name: existing.name, name: existing.name,
email, email,
username: "new-username", username: "new-username",
@@ -65,9 +66,10 @@ describe("userCreator", () => {
}, },
}); });
const { user, authentication, isNewUser } = result; const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123"); expect(authentication).toBeDefined();
expect(authentication.scopes.length).toEqual(1); expect(authentication?.accessToken).toEqual("123");
expect(authentication.scopes[0]).toEqual("read"); expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications"); const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1); expect(authentications.length).toEqual(1);
@@ -85,7 +87,7 @@ describe("userCreator", () => {
teamId: team.id, teamId: team.id,
}); });
const result = await userCreator({ const result = await userProvisioner({
name: existing.name, name: existing.name,
email, email,
username: "new-username", username: "new-username",
@@ -100,9 +102,10 @@ describe("userCreator", () => {
}, },
}); });
const { user, authentication, isNewUser } = result; const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123"); expect(authentication).toBeDefined();
expect(authentication.scopes.length).toEqual(1); expect(authentication?.accessToken).toEqual("123");
expect(authentication.scopes[0]).toEqual("read"); expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications"); const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1); expect(authentications.length).toEqual(1);
@@ -115,7 +118,7 @@ describe("userCreator", () => {
const existingAuth = authentications[0]; const existingAuth = authentications[0];
const newEmail = "test@example.com"; const newEmail = "test@example.com";
await existing.destroy(); await existing.destroy();
const result = await userCreator({ const result = await userProvisioner({
name: "Test Name", name: "Test Name",
email: "test@example.com", email: "test@example.com",
teamId: existing.teamId, teamId: existing.teamId,
@@ -128,9 +131,10 @@ describe("userCreator", () => {
}, },
}); });
const { user, authentication, isNewUser } = result; const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123"); expect(authentication).toBeDefined();
expect(authentication.scopes.length).toEqual(1); expect(authentication?.accessToken).toEqual("123");
expect(authentication.scopes[0]).toEqual("read"); expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual(newEmail); expect(user.email).toEqual(newEmail);
expect(isNewUser).toEqual(true); expect(isNewUser).toEqual(true);
}); });
@@ -142,13 +146,13 @@ describe("userCreator", () => {
let error; let error;
try { try {
await userCreator({ await userProvisioner({
name: "Test Name", name: "Test Name",
email: "test@example.com", email: "test@example.com",
teamId: existing.teamId, teamId: existing.teamId,
ip, ip,
authentication: { authentication: {
authenticationProviderId: "example.org", authenticationProviderId: uuidv4(),
providerId: existingAuth.providerId, providerId: existingAuth.providerId,
accessToken: "123", accessToken: "123",
scopes: ["read"], scopes: ["read"],
@@ -165,7 +169,7 @@ describe("userCreator", () => {
const team = await buildTeam(); const team = await buildTeam();
const authenticationProviders = await team.$get("authenticationProviders"); const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0]; const authenticationProvider = authenticationProviders[0];
const result = await userCreator({ const result = await userProvisioner({
name: "Test Name", name: "Test Name",
email: "test@example.com", email: "test@example.com",
username: "tname", username: "tname",
@@ -179,9 +183,10 @@ describe("userCreator", () => {
}, },
}); });
const { user, authentication, isNewUser } = result; const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123"); expect(authentication).toBeDefined();
expect(authentication.scopes.length).toEqual(1); expect(authentication?.accessToken).toEqual("123");
expect(authentication.scopes[0]).toEqual("read"); expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual("test@example.com"); expect(user.email).toEqual("test@example.com");
expect(user.username).toEqual("tname"); expect(user.username).toEqual("tname");
expect(user.isAdmin).toEqual(false); expect(user.isAdmin).toEqual(false);
@@ -195,7 +200,7 @@ describe("userCreator", () => {
}); });
const authenticationProviders = await team.$get("authenticationProviders"); const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0]; const authenticationProvider = authenticationProviders[0];
const result = await userCreator({ const result = await userProvisioner({
name: "Test Name", name: "Test Name",
email: "test@example.com", email: "test@example.com",
username: "tname", username: "tname",
@@ -219,7 +224,7 @@ describe("userCreator", () => {
}); });
const authenticationProviders = await team.$get("authenticationProviders"); const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0]; const authenticationProvider = authenticationProviders[0];
const result = await userCreator({ const result = await userProvisioner({
name: "Test Name", name: "Test Name",
email: "test@example.com", email: "test@example.com",
username: "tname", username: "tname",
@@ -236,7 +241,7 @@ describe("userCreator", () => {
expect(tname.username).toEqual("tname"); expect(tname.username).toEqual("tname");
expect(tname.isAdmin).toEqual(false); expect(tname.isAdmin).toEqual(false);
expect(tname.isViewer).toEqual(true); expect(tname.isViewer).toEqual(true);
const tname2Result = await userCreator({ const tname2Result = await userProvisioner({
name: "Test2 Name", name: "Test2 Name",
email: "tes2@example.com", email: "tes2@example.com",
username: "tname2", username: "tname2",
@@ -264,7 +269,7 @@ describe("userCreator", () => {
}); });
const authenticationProviders = await team.$get("authenticationProviders"); const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0]; const authenticationProvider = authenticationProviders[0];
const result = await userCreator({ const result = await userProvisioner({
name: invite.name, name: invite.name,
email: "invite@ExamPle.com", email: "invite@ExamPle.com",
teamId: invite.teamId, teamId: invite.teamId,
@@ -277,13 +282,46 @@ describe("userCreator", () => {
}, },
}); });
const { user, authentication, isNewUser } = result; const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123"); expect(authentication).toBeDefined();
expect(authentication.scopes.length).toEqual(1); expect(authentication?.accessToken).toEqual("123");
expect(authentication.scopes[0]).toEqual("read"); expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual(invite.email); expect(user.email).toEqual(invite.email);
expect(isNewUser).toEqual(true); expect(isNewUser).toEqual(true);
}); });
it("should create a user from an invited user using email match", async () => {
const externalUser = await buildUser({
email: "external@example.com",
});
const team = await buildTeam({ inviteRequired: true });
const invite = await buildInvite({
teamId: team.id,
email: externalUser.email,
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const result = await userProvisioner({
name: invite.name,
email: "external@ExamPle.com", // ensure that email is case insensistive
teamId: invite.teamId,
emailMatchOnly: true,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: "whatever",
accessToken: "123",
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication).toEqual(null);
expect(user.id).toEqual(invite.id);
expect(isNewUser).toEqual(true);
});
it("should reject an uninvited user when invites are required", async () => { it("should reject an uninvited user when invites are required", async () => {
const team = await buildTeam({ inviteRequired: true }); const team = await buildTeam({ inviteRequired: true });
@@ -292,7 +330,7 @@ describe("userCreator", () => {
let error; let error;
try { try {
await userCreator({ await userProvisioner({
name: "Uninvited User", name: "Uninvited User",
email: "invite@ExamPle.com", email: "invite@ExamPle.com",
teamId: team.id, teamId: team.id,
@@ -323,7 +361,7 @@ describe("userCreator", () => {
const authenticationProviders = await team.$get("authenticationProviders"); const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0]; const authenticationProvider = authenticationProviders[0];
const result = await userCreator({ const result = await userProvisioner({
name: "Test Name", name: "Test Name",
email: "user@example-company.com", email: "user@example-company.com",
teamId: team.id, teamId: team.id,
@@ -336,9 +374,10 @@ describe("userCreator", () => {
}, },
}); });
const { user, authentication, isNewUser } = result; const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123"); expect(authentication).toBeDefined();
expect(authentication.scopes.length).toEqual(1); expect(authentication?.accessToken).toEqual("123");
expect(authentication.scopes[0]).toEqual("read"); expect(authentication?.scopes.length).toEqual(1);
expect(authentication?.scopes[0]).toEqual("read");
expect(user.email).toEqual("user@example-company.com"); expect(user.email).toEqual("user@example-company.com");
expect(isNewUser).toEqual(true); expect(isNewUser).toEqual(true);
}); });
@@ -356,7 +395,7 @@ describe("userCreator", () => {
let error; let error;
try { try {
await userCreator({ await userProvisioner({
name: "Bad Domain User", name: "Bad Domain User",
email: "user@example.com", email: "user@example.com",
teamId: team.id, teamId: team.id,

View File

@@ -1,12 +1,16 @@
import { sequelize } from "@server/database/sequelize"; import { sequelize } from "@server/database/sequelize";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail"; import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import { DomainNotAllowedError, InviteRequiredError } from "@server/errors"; import {
DomainNotAllowedError,
InvalidAuthenticationError,
InviteRequiredError,
} from "@server/errors";
import { Event, Team, User, UserAuthentication } from "@server/models"; import { Event, Team, User, UserAuthentication } from "@server/models";
type UserCreatorResult = { type UserProvisionerResult = {
user: User; user: User;
isNewUser: boolean; isNewUser: boolean;
authentication: UserAuthentication; authentication: UserAuthentication | null;
}; };
type Props = { type Props = {
@@ -16,6 +20,7 @@ type Props = {
isAdmin?: boolean; isAdmin?: boolean;
avatarUrl?: string | null; avatarUrl?: string | null;
teamId: string; teamId: string;
emailMatchOnly?: boolean;
ip: string; ip: string;
authentication: { authentication: {
authenticationProviderId: string; authenticationProviderId: string;
@@ -27,17 +32,18 @@ type Props = {
}; };
}; };
export default async function userCreator({ export default async function userProvisioner({
name, name,
email, email,
username, username,
isAdmin, isAdmin,
emailMatchOnly,
avatarUrl, avatarUrl,
teamId, teamId,
authentication, authentication,
ip, ip,
}: Props): Promise<UserCreatorResult> { }: Props): Promise<UserProvisionerResult> {
const { authenticationProviderId, providerId, ...rest } = authentication; const { providerId, authenticationProviderId, ...rest } = authentication;
const auth = await UserAuthentication.findOne({ const auth = await UserAuthentication.findOne({
where: { where: {
@@ -87,9 +93,8 @@ export default async function userCreator({
await auth.destroy(); await auth.destroy();
} }
// A `user` record might exist in the form of an invite even if there is no // A `user` record may exist even if there is no existing authentication record.
// existing authentication record that matches. In Outline an invite is a // This is either an invite or a user that's external to the team
// shell user record.
const existingUser = await User.scope([ const existingUser = await User.scope([
"withAuthentications", "withAuthentications",
"withTeam", "withTeam",
@@ -102,12 +107,11 @@ export default async function userCreator({
}, },
}); });
// We have an existing invite for his user, so we need to update it with our // We have an existing user, so we need to update it with our
// new details, link up the authentication method, and count this as a new // new details and count this as a new user creation.
// user creation.
if (existingUser) { if (existingUser) {
// A `user` record might exist in the form of an invite. // A `user` record might exist in the form of an invite.
// In Outline an invite is a shell user record with no authentication method // An invite is a shell user record with no authentication method
// that's never been active before. // that's never been active before.
const isInvite = existingUser.isInvited; const isInvite = existingUser.isInvited;
@@ -145,6 +149,12 @@ export default async function userCreator({
} }
); );
// We don't want to associate a user auth with the auth provider
// if we're doing a simple email match, so early return here
if (emailMatchOnly) {
return null;
}
return await existingUser.$create<UserAuthentication>( return await existingUser.$create<UserAuthentication>(
"authentication", "authentication",
authentication, authentication,
@@ -171,9 +181,16 @@ export default async function userCreator({
authentication: auth, authentication: auth,
isNewUser: isInvite, isNewUser: isInvite,
}; };
} else if (emailMatchOnly) {
// There's no existing invite or user that matches the external auth email
// This is simply unauthorized
throw InvalidAuthenticationError();
} }
//
// No auth, no user this is an entirely new sign in. // No auth, no user this is an entirely new sign in.
//
const transaction = await User.sequelize!.transaction(); const transaction = await User.sequelize!.transaction();
try { try {

View File

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

View File

@@ -11,7 +11,6 @@ import accountProvisioner, {
import env from "@server/env"; import env from "@server/env";
import { import {
GmailAccountCreationError, GmailAccountCreationError,
InviteRequiredError,
TeamDomainRequiredError, TeamDomainRequiredError,
} from "@server/errors"; } from "@server/errors";
import passportMiddleware from "@server/middlewares/passport"; import passportMiddleware from "@server/middlewares/passport";
@@ -19,7 +18,7 @@ import { User } from "@server/models";
import { StateStore, getTeamFromContext } from "@server/utils/passport"; import { StateStore, getTeamFromContext } from "@server/utils/passport";
const router = new Router(); const router = new Router();
const providerName = "google"; const GOOGLE = "google";
const scopes = [ const scopes = [
"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.email",
@@ -34,7 +33,7 @@ type GoogleProfile = Profile & {
email: string; email: string;
picture: string; picture: string;
_json: { _json: {
hd: string; hd?: string;
}; };
}; };
@@ -63,87 +62,66 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
) => void ) => void
) { ) {
try { try {
let result;
// "domain" is the Google Workspaces domain // "domain" is the Google Workspaces domain
const domain = profile._json.hd; const domain = profile._json.hd;
const team = await getTeamFromContext(ctx); const team = await getTeamFromContext(ctx);
// Existence of domain means this is a Google Workspaces account // No profile domain means personal gmail account
// so we'll attempt to provision an account (team and user) // No team implies the request came from the apex domain
if (domain) { // This combination is always an error
// remove the TLD and form a subdomain from the remaining if (!domain && !team) {
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains const userExists = await User.count({
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain where: { email: profile.email.toLowerCase() },
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() },
}); });
if (!user) { // Users cannot create a team with personal gmail accounts
throw InviteRequiredError(); if (!userExists) {
throw GmailAccountCreationError();
} }
result = { // To log-in with a personal account, users must specify a team subdomain
user, throw TeamDomainRequiredError();
team,
isNewUser: false,
isNewTeam: false,
};
} }
// 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); return done(null, result.user, result);
} catch (err) { } catch (err) {
return done(err, null); return done(err, null);
@@ -154,13 +132,13 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
router.get( router.get(
"google", "google",
passport.authenticate(providerName, { passport.authenticate(GOOGLE, {
accessType: "offline", accessType: "offline",
prompt: "select_account consent", prompt: "select_account consent",
}) })
); );
router.get("google.callback", passportMiddleware(providerName)); router.get("google.callback", passportMiddleware(GOOGLE));
} }
export default router; export default router;

View File

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

View File

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