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:
259
server/commands/userProvisioner.ts
Normal file
259
server/commands/userProvisioner.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
|
||||
import {
|
||||
DomainNotAllowedError,
|
||||
InvalidAuthenticationError,
|
||||
InviteRequiredError,
|
||||
} from "@server/errors";
|
||||
import { Event, Team, User, UserAuthentication } from "@server/models";
|
||||
|
||||
type UserProvisionerResult = {
|
||||
user: User;
|
||||
isNewUser: boolean;
|
||||
authentication: UserAuthentication | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
avatarUrl?: string | null;
|
||||
teamId: string;
|
||||
emailMatchOnly?: boolean;
|
||||
ip: string;
|
||||
authentication: {
|
||||
authenticationProviderId: string;
|
||||
providerId: string;
|
||||
scopes: string[];
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function userProvisioner({
|
||||
name,
|
||||
email,
|
||||
username,
|
||||
isAdmin,
|
||||
emailMatchOnly,
|
||||
avatarUrl,
|
||||
teamId,
|
||||
authentication,
|
||||
ip,
|
||||
}: Props): Promise<UserProvisionerResult> {
|
||||
const { providerId, authenticationProviderId, ...rest } = authentication;
|
||||
|
||||
const auth = await UserAuthentication.findOne({
|
||||
where: {
|
||||
providerId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "user",
|
||||
where: { teamId },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Someone has signed in with this authentication before, we just
|
||||
// want to update the details instead of creating a new record
|
||||
if (auth) {
|
||||
const { user } = auth;
|
||||
|
||||
// We found an authentication record that matches the user id, but it's
|
||||
// associated with a different authentication provider, (eg a different
|
||||
// hosted google domain). This is possible in Google Auth when moving domains.
|
||||
// In the future we may auto-migrate these.
|
||||
if (auth.authenticationProviderId !== authenticationProviderId) {
|
||||
throw new Error(
|
||||
`User authentication ${providerId} already exists for ${auth.authenticationProviderId}, tried to assign to ${authenticationProviderId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
await user.update({
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await auth.update(rest);
|
||||
return {
|
||||
user,
|
||||
authentication: auth,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// We found an authentication record, but the associated user was deleted or
|
||||
// otherwise didn't exist. Cleanup the auth record and proceed with creating
|
||||
// a new user. See: https://github.com/outline/outline/issues/2022
|
||||
await auth.destroy();
|
||||
}
|
||||
|
||||
// A `user` record may exist even if there is no existing authentication record.
|
||||
// This is either an invite or a user that's external to the team
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// We have an existing user, so we need to update it with our
|
||||
// new details and count this as a new user creation.
|
||||
if (existingUser) {
|
||||
// A `user` record might exist in the form of an invite.
|
||||
// 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) => {
|
||||
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,
|
||||
}
|
||||
);
|
||||
|
||||
// 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>(
|
||||
"authentication",
|
||||
authentication,
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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: existingUser,
|
||||
authentication: auth,
|
||||
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.
|
||||
//
|
||||
|
||||
const transaction = await User.sequelize!.transaction();
|
||||
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
attributes: ["defaultUserRole", "inviteRequired", "id"],
|
||||
transaction,
|
||||
});
|
||||
|
||||
// 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) {
|
||||
throw InviteRequiredError();
|
||||
}
|
||||
|
||||
// If the team settings do not allow this domain,
|
||||
// throw an error and fail user creation.
|
||||
const domain = email.split("@")[1];
|
||||
if (team && !(await team.isDomainAllowed(domain))) {
|
||||
throw DomainNotAllowedError();
|
||||
}
|
||||
|
||||
const defaultUserRole = team?.defaultUserRole;
|
||||
|
||||
const user = await User.create(
|
||||
{
|
||||
name,
|
||||
email,
|
||||
username,
|
||||
isAdmin: typeof isAdmin === "boolean" && isAdmin,
|
||||
isViewer: isAdmin === true ? false : defaultUserRole === "viewer",
|
||||
teamId,
|
||||
avatarUrl,
|
||||
service: null,
|
||||
authentications: [authentication],
|
||||
},
|
||||
{
|
||||
include: "authentications",
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.create",
|
||||
actorId: user.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
data: {
|
||||
name: user.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
await transaction.commit();
|
||||
return {
|
||||
user,
|
||||
authentication: user.authentications[0],
|
||||
isNewUser: true,
|
||||
};
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user