Compare commits

...

2 Commits

Author SHA1 Message Date
Tom Moor d6b2889f1d wip 2026-01-10 00:26:57 -05:00
Tom Moor 69a3346280 fix: Remove stale UserAuthentication records on sign-in
When signing in with an authentication method, remove any other
UserAuthentication records for the same provider to prevent expired
tokens from causing premature logout.

This fix applies to both:
- Existing users signing in again
- Invited users accepting their invitation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 00:13:33 -05:00
2 changed files with 163 additions and 0 deletions
+143
View File
@@ -41,6 +41,75 @@ describe("userProvisioner", () => {
expect(isNewUser).toEqual(false);
});
it("should remove all other UserAuthentication records on sign-in", async () => {
const existing = await buildUser();
const authentications = await existing.$get("authentications");
const existingAuth = authentications[0];
// Create a second authentication record for the same provider (simulating a stale/duplicate record)
const staleAuthSameProvider = await existing.$create("authentication", {
authenticationProviderId: existingAuth.authenticationProviderId,
providerId: randomUUID(),
accessToken: "old-token",
scopes: ["read"],
});
// Create a third authentication record for a different provider
const team = await existing.$get("team");
const otherProvider = await team!.$create("authenticationProvider", {
name: "other-provider",
providerId: randomUUID(),
});
const staleAuthDifferentProvider = await existing.$create(
"authentication",
{
authenticationProviderId: otherProvider.id,
providerId: randomUUID(),
accessToken: "other-provider-token",
scopes: ["read"],
}
);
// Verify we have 3 auth records
const authsBefore = await existing.$get("authentications");
expect(authsBefore.length).toEqual(3);
// Sign in with the original providerId
const result = await userProvisioner(ctx, {
name: existing.name,
email: existing.email!,
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
authentication: {
authenticationProviderId: existingAuth.authenticationProviderId,
providerId: existingAuth.providerId,
accessToken: "new-token",
scopes: ["read", "write"],
},
});
const { user, authentication } = result;
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("new-token");
// Verify all stale authentications were removed (including from different provider)
const authsAfter = await user.$get("authentications");
expect(authsAfter.length).toEqual(1);
expect(authsAfter[0].id).toEqual(existingAuth.id);
expect(authsAfter[0].accessToken).toEqual("new-token");
// Verify both stale auths were actually deleted from the database
const { UserAuthentication } = await import("@server/models");
const deletedAuth1 = await UserAuthentication.findByPk(
staleAuthSameProvider.id
);
expect(deletedAuth1).toBeNull();
const deletedAuth2 = await UserAuthentication.findByPk(
staleAuthDifferentProvider.id
);
expect(deletedAuth2).toBeNull();
});
it("should add authentication provider to existing users", async () => {
const team = await buildTeam({ inviteRequired: true });
const teamAuthProviders = await team.$get("authenticationProviders");
@@ -110,6 +179,80 @@ describe("userProvisioner", () => {
expect(isNewUser).toEqual(true);
});
it("should remove all stale authentications when adding to invited user", 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,
});
// Create a stale authentication record for the same provider
const staleAuthSameProvider = await existing.$create("authentication", {
authenticationProviderId: authenticationProvider.id,
providerId: randomUUID(),
accessToken: "old-expired-token",
scopes: ["read"],
});
// Create a stale authentication record for a different provider
const otherProvider = await team.$create("authenticationProvider", {
name: "other-provider",
providerId: randomUUID(),
});
const staleAuthDifferentProvider = await existing.$create(
"authentication",
{
authenticationProviderId: otherProvider.id,
providerId: randomUUID(),
accessToken: "other-provider-token",
scopes: ["read"],
}
);
// Verify the stale auths exist
const authsBefore = await existing.$get("authentications");
expect(authsBefore.length).toEqual(2);
// Sign in with a new providerId
const result = await userProvisioner(ctx, {
name: existing.name,
email,
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: randomUUID(),
accessToken: "new-token",
scopes: ["read", "write"],
},
});
const { user, authentication, isNewUser } = result;
expect(authentication).toBeDefined();
expect(authentication?.accessToken).toEqual("new-token");
expect(isNewUser).toEqual(true);
// Verify only the new authentication exists
const authsAfter = await user.$get("authentications");
expect(authsAfter.length).toEqual(1);
expect(authsAfter[0].accessToken).toEqual("new-token");
// Verify both stale auths were deleted
const { UserAuthentication } = await import("@server/models");
const deletedAuth1 = await UserAuthentication.findByPk(
staleAuthSameProvider.id
);
expect(deletedAuth1).toBeNull();
const deletedAuth2 = await UserAuthentication.findByPk(
staleAuthDifferentProvider.id
);
expect(deletedAuth2).toBeNull();
});
it("should create user with deleted user matching providerId", async () => {
const existing = await buildUser();
const authentications = await existing.$get("authentications");
+20
View File
@@ -99,6 +99,17 @@ export default async function userProvisioner(
await user.update({ email });
await auth.update(rest);
// Remove any other UserAuthentication records for the user
// to prevent expired tokens from causing premature logout
await UserAuthentication.destroy({
where: {
userId: user.id,
id: {
[Op.ne]: auth.id,
},
},
});
return {
user,
authentication: auth,
@@ -160,6 +171,15 @@ export default async function userProvisioner(
return null;
}
// Remove any other UserAuthentication records for the user
// to prevent expired tokens from causing premature logout
await UserAuthentication.destroy({
where: {
userId: existingUser.id,
},
transaction,
});
return await existingUser.$create<UserAuthentication>(
"authentication",
authentication,