diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index bddf0a31..d2fbbb51 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -555,14 +555,34 @@ export class Team extends EventEmitter { return invitations.validate(proof, invitation) } + /** Check if userId and userName are not used by any other member within the team. */ + public validateUser = (userId: string, userName: string) => { + const memberWithSameUserId = this.members().find(member => member.userId === userId) + if (memberWithSameUserId !== undefined) { + return invitations.fail('userId is not unique within the team.') + } + + const memberWithSameUserName = this.members().find( + member => member.userName.toLowerCase() === userName.toLowerCase() + ) + if (memberWithSameUserName !== undefined) { + return invitations.fail('Username is not unique within the team.') + } + + return VALID + } + /** An existing team member calls this to admit a new member & their device to the team based on proof of invitation */ public admitMember = ( proof: ProofOfInvitation, memberKeys: Keyset | KeysetWithSecrets, // We accept KeysetWithSecrets here to simplify testing - in practice we'll only receive Keyset userName: string // The new member's desired user-facing name ) => { - const validation = this.validateInvitation(proof) - if (!validation.isValid) throw validation.error + const invitationValidation = this.validateInvitation(proof) + if (!invitationValidation.isValid) throw invitationValidation.error + + const userValidation = this.validateUser(memberKeys.name, userName) + if (!userValidation.isValid) throw userValidation.error const { id } = proof diff --git a/packages/auth/src/team/test/invitations.test.ts b/packages/auth/src/team/test/invitations.test.ts index 36347852..e08f8d26 100644 --- a/packages/auth/src/team/test/invitations.test.ts +++ b/packages/auth/src/team/test/invitations.test.ts @@ -80,7 +80,7 @@ describe('Team', () => { expect(bobsTeam.memberIsAdmin(bob.userId)).toBe(false) // ๐Ÿ‘ณ๐Ÿฝโ€โ™‚๏ธ Charlie shows ๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฒ Bob his proof of invitation - bobsTeam.admitMember(proofOfInvitation, charlie.user.keys, bob.user.userName) + bobsTeam.admitMember(proofOfInvitation, charlie.user.keys, charlie.user.userName) // ๐Ÿ‘๐Ÿ‘ณ๐Ÿฝโ€โ™‚๏ธ Charlie is now on the team expect(bobsTeam.has(charlie.userId)).toBe(true) @@ -336,6 +336,58 @@ describe('Team', () => { ).not.toThrow() }) + it("won't accept proof of invitation with a username that is not unique", () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + + // ๐Ÿ‘ฉ๐Ÿพ Alice invites ๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฒ Bob by sending him a random secret key + const { seed } = alice.team.inviteMember() + + // ๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฒ Bob accepts the invitation + const proofOfInvitation = generateProof(seed) + + // ๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฒ Bob shows ๐Ÿ‘ฉ๐Ÿพ Alice his proof of invitation, but uses Alice's username + const tryToAdmitBob = () => { + alice.team.admitMember(proofOfInvitation, bob.user.keys, alice.user.userName) + } + + // ๐Ÿ‘Ž But the invitation is rejected because the username is not unique + expect(tryToAdmitBob).toThrowError('Username is not unique within the team.') + + // โŒ ๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฒ Bob is not on the team + expect(alice.team.has(bob.userId)).toBe(false) + }) + + it("won't accept proof of invitation with a userId that is not unique", () => { + const { alice, eve } = setup('alice', { user: 'eve', member: false }) + + // ๐Ÿ‘ฉ๐Ÿพ Alice invites ๐Ÿฆนโ€โ™€๏ธ Eve by sending her a random secret key + const { seed } = alice.team.inviteMember() + + // ๐Ÿฆนโ€โ™€๏ธ Eve accepts the invitation + const proofOfInvitation = generateProof(seed) + + // ๐Ÿฆนโ€โ™€๏ธ Eve prepares keys using Alice's userId + const keysWithAliceUserId = { + ...eve.user.keys, + name: alice.userId, + } + + // ๐Ÿฆนโ€โ™€๏ธ Eve shows ๐Ÿ‘ฉ๐Ÿพ Alice her proof of invitation, but uses Alice's userId + const tryToAdmitEve = () => { + alice.team.admitMember(proofOfInvitation, keysWithAliceUserId, eve.user.userName) + } + + // ๐Ÿ‘Ž But the invitation is rejected because the userId is not unique + expect(tryToAdmitEve).toThrowError('userId is not unique within the team.') + + // โŒ ๐Ÿฆนโ€โ™€๏ธ Eve is not on the team + expect(alice.team.has(eve.userId)).toBe(false) + expect( + alice.team.state.members.filter(({ userId }) => userId === alice.userId) + ).toHaveLength(1) + expect(alice.team.members(alice.userId).userName === alice.userName).toBe(true) + }) + describe('devices', () => { it('creates and accepts an invitation for a device', () => { const { alice: aliceLaptop } = setup('alice') diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index 33ebc884..b65232d5 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -112,6 +112,30 @@ const validators: TeamStateValidatorSet = { } return VALID }, + + /** Check if userId and userName are not used by any other member within the team */ + uniqueUserNameAndId(...args) { + const [previousState, link] = args + if (link.body.type === 'ADMIT_MEMBER') { + const { userName, memberKeys } = link.body.payload + + const memberWithSameUserId = previousState.members.find( + member => member.userId === memberKeys.name + ) + if (memberWithSameUserId !== undefined) { + return fail('userId is not unique within the team.', ...args) + } + + const memberWithSameUserName = previousState.members.find( + member => member.userName.toLowerCase() === userName.toLowerCase() + ) + + if (memberWithSameUserName !== undefined) { + return fail('Username is not unique within the team.', ...args) + } + } + return VALID + }, } const fail = (message: string, previousState: TeamState, link: TeamLink) => {