Feat: add auth provider to users on sign in (#3739)

* feat: merge a new authentication method onto existing user records when emails match

* adds test for invite acceptance and auth provider creation

* addresses comments
- test existing user and invites in different test cases
- update lastActiveAt syncronously when an invite is accepted

* sort arrays in test to prevent nondeterministic test behaivior when doing array compare
This commit is contained in:
Nan Yu
2022-07-08 00:24:46 -07:00
committed by GitHub
parent ec8c0645ba
commit 1e808fc52c
4 changed files with 125 additions and 37 deletions

View File

@@ -1,3 +1,4 @@
import { v4 as uuidv4 } from "uuid";
import { TeamDomain } from "@server/models";
import { buildUser, buildTeam, buildInvite } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
@@ -37,6 +38,77 @@ describe("userCreator", () => {
expect(isNewUser).toEqual(false);
});
it("should add authentication provider to existing users", async () => {
const team = await buildTeam({ inviteRequired: true });
const teamAuthProviders = await team.$get("authenticationProviders");
const authenticationProvider = teamAuthProviders[0];
const email = "mynam@email.com";
const existing = await buildUser({
email,
teamId: team.id,
authentications: [],
});
const result = await userCreator({
name: existing.name,
email,
username: "new-username",
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: uuidv4(),
accessToken: "123",
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1);
expect(isNewUser).toEqual(false);
});
it("should add authentication provider to invited users", async () => {
const team = await buildTeam({ inviteRequired: true });
const teamAuthProviders = await team.$get("authenticationProviders");
const authenticationProvider = teamAuthProviders[0];
const email = "mynam@email.com";
const existing = await buildInvite({
email,
teamId: team.id,
});
const result = await userCreator({
name: existing.name,
email,
username: "new-username",
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: uuidv4(),
accessToken: "123",
scopes: ["read"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication.accessToken).toEqual("123");
expect(authentication.scopes.length).toEqual(1);
expect(authentication.scopes[0]).toEqual("read");
const authentications = await user.$get("authentications");
expect(authentications.length).toEqual(1);
expect(isNewUser).toEqual(true);
});
it("should create user with deleted user matching providerId", async () => {
const existing = await buildUser();
const authentications = await existing.$get("authentications");

View File

@@ -1,4 +1,3 @@
import { Op } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import { DomainNotAllowedError, InviteRequiredError } from "@server/errors";
@@ -89,48 +88,62 @@ export default async function userCreator({
// A `user` record might exist in the form of an invite even if there is no
// existing authentication record that matches. In Outline an invite is a
// shell user record.
const invite = await User.scope(["withAuthentications", "withTeam"]).findOne({
const existingUser = await User.scope([
"withAuthentications",
"withTeam",
]).findOne({
where: {
// Email from auth providers may be capitalized and we should respect that
// however any existing invites will always be lowercased.
email: email.toLowerCase(),
teamId,
lastActiveAt: {
[Op.is]: null,
},
},
});
// We have an existing invite for his user, so we need to update it with our
// new details, link up the authentication method, and count this as a new
// user creation.
if (invite && !invite.authentications.length) {
if (existingUser) {
// A `user` record might exist in the form of an invite.
// In Outline an invite is a shell user record with no authentication method
// that's never been active before.
const isInvite = existingUser.isInvited;
const auth = await sequelize.transaction(async (transaction) => {
await invite.update(
if (isInvite) {
await Event.create(
{
name: "users.create",
actorId: existingUser.id,
userId: existingUser.id,
teamId: existingUser.teamId,
data: {
name,
},
ip,
},
{
transaction,
}
);
}
// Regardless, create a new authentication record
// against the existing user (user can auth with multiple SSO providers)
// Update user's name and avatar based on the most recently added provider
await existingUser.update(
{
name,
avatarUrl,
lastActiveAt: new Date(),
lastActiveIp: ip,
},
{
transaction,
}
);
await Event.create(
{
name: "users.create",
actorId: invite.id,
userId: invite.id,
teamId: invite.teamId,
data: {
name,
},
ip,
},
{
transaction,
}
);
return await invite.$create<UserAuthentication>(
return await existingUser.$create<UserAuthentication>(
"authentication",
authentication,
{
@@ -139,20 +152,22 @@ export default async function userCreator({
);
});
const inviter = await invite.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
to: inviter.email,
inviterId: inviter.id,
invitedName: invite.name,
teamUrl: invite.team.url,
});
if (isInvite) {
const inviter = await existingUser.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
to: inviter.email,
inviterId: inviter.id,
invitedName: existingUser.name,
teamUrl: existingUser.team.url,
});
}
}
return {
user: invite,
user: existingUser,
authentication: auth,
isNewUser: true,
isNewUser: isInvite,
};
}
@@ -165,9 +180,9 @@ export default async function userCreator({
transaction,
});
// If the team settings are set to require invites, and the user is not already invited,
// If the team settings are set to require invites, and there's no existing user record,
// throw an error and fail user creation.
if (team?.inviteRequired && !invite) {
if (team?.inviteRequired) {
throw InviteRequiredError();
}