-
Notifications
You must be signed in to change notification settings - Fork 101
fix: resolve race condition in Clerk auth signup flow to prevent duplicate login attempts #3325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "jazz-tools": patch | ||
| --- | ||
|
|
||
| Bugfix: fixed a race condition in Clerk auth where the signup flow could trigger a duplicate login attempt | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,18 +53,21 @@ export class JazzClerkAuth { | |
| } | ||
|
|
||
| private isFirstCall = true; | ||
| private previousUser: Pick< | ||
| NonNullable<MinimalClerkClient["user"]>, | ||
| "unsafeMetadata" | ||
| > | null = null; | ||
|
Comment on lines
+56
to
+59
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: I'd extract the repeated type into a named type
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trying to clean up the module, there is a bit of unnecessary code that's here for tech debt. Going to do it in a follow-up PR, so I can release this fix ASAP. |
||
|
|
||
| registerListener(clerkClient: MinimalClerkClient) { | ||
| let previousUser: MinimalClerkClient["user"] | null = | ||
| clerkClient.user ?? null; | ||
| this.previousUser = clerkClient.user ?? null; | ||
|
|
||
| // Need to use addListener because the clerk user object is not updated when the user logs in | ||
| return clerkClient.addListener((event) => { | ||
| const user = (event as Pick<MinimalClerkClient, "user">).user ?? null; | ||
|
|
||
| if (!isClerkAuthStateEqual(previousUser, user) || this.isFirstCall) { | ||
| if (!isClerkAuthStateEqual(this.previousUser, user) || this.isFirstCall) { | ||
| this.previousUser = user; | ||
| this.onClerkUserChange({ user }); | ||
| previousUser = user; | ||
| this.isFirstCall = false; | ||
| } | ||
| }); | ||
|
|
@@ -137,13 +140,20 @@ export class JazzClerkAuth { | |
| ? Array.from(credentials.secretSeed) | ||
| : undefined; | ||
|
|
||
| await clerkClient.user?.update({ | ||
| unsafeMetadata: { | ||
| jazzAccountID: credentials.accountID, | ||
| jazzAccountSecret: credentials.accountSecret, | ||
| jazzAccountSeed, | ||
| } satisfies ClerkCredentials, | ||
| }); | ||
| const clerkCredentials = { | ||
| jazzAccountID: credentials.accountID, | ||
| jazzAccountSecret: credentials.accountSecret, | ||
| jazzAccountSeed, | ||
| }; | ||
| // user.update will cause the Clerk user change listener to fire; updating this.previousUser beforehand | ||
| // ensures the listener sees the new credentials and does not trigger an unnecessary logIn operation | ||
| this.previousUser = { unsafeMetadata: clerkCredentials }; | ||
|
|
||
| if (clerkClient.user) { | ||
| await clerkClient.user.update({ | ||
| unsafeMetadata: clerkCredentials, | ||
| }); | ||
| } | ||
|
|
||
| const currentAccount = await Account.getMe().$jazz.ensureLoaded({ | ||
| resolve: { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -282,6 +282,83 @@ describe("JazzClerkAuth", () => { | |
|
|
||
| expect(onClerkUserChangeSpy).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("should complete signup flow when new Clerk user is detected", async () => { | ||
| // 1. Setup local credentials (simulating anonymous user) | ||
| await authSecretStorage.set({ | ||
| accountID: "test-account-id" as ID<Account>, | ||
| secretSeed: new Uint8Array([1, 2, 3]), | ||
| accountSecret: "test-secret" as AgentSecret, | ||
| provider: "anonymous", | ||
| }); | ||
|
|
||
| const { client, triggerUserChange } = setupMockClerk(null); | ||
|
|
||
| const auth = new JazzClerkAuth( | ||
| mockAuthenticate, | ||
| mockLogOut, | ||
| authSecretStorage, | ||
| ); | ||
|
|
||
| // 2. Register listener with null user (no one logged in yet) | ||
| auth.registerListener(client); | ||
|
|
||
| // Initial trigger with no user | ||
| triggerUserChange(null); | ||
|
|
||
| // 3. Trigger event with new Clerk user (no Jazz credentials yet) | ||
| const mockUserUpdate = vi.fn((data) => { | ||
| triggerUserChange(data); | ||
| }); | ||
|
|
||
| const signInSpy = vi.spyOn(auth, "signIn"); | ||
| const logInSpy = vi.spyOn(auth, "logIn"); | ||
|
|
||
| const newClerkUser = { | ||
| fullName: "Test User", | ||
| firstName: "Test", | ||
| lastName: "User", | ||
| username: "testuser", | ||
| id: "clerk-user-123", | ||
| primaryEmailAddress: { emailAddress: "[email protected]" }, | ||
| unsafeMetadata: {}, // No Jazz credentials yet | ||
| update: mockUserUpdate, | ||
| }; | ||
|
|
||
| triggerUserChange(newClerkUser); | ||
|
|
||
| // Wait for async operations to complete | ||
| await vi.waitFor(() => { | ||
| expect(mockUserUpdate).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| // 4. Verify credentials synced to Clerk | ||
| expect(mockUserUpdate).toHaveBeenCalledWith({ | ||
| unsafeMetadata: { | ||
| jazzAccountID: "test-account-id", | ||
| jazzAccountSecret: "test-secret", | ||
| jazzAccountSeed: [1, 2, 3], | ||
| }, | ||
| }); | ||
|
|
||
| // Verify profile name was updated from Clerk username | ||
| const me = await Account.getMe().$jazz.ensureLoaded({ | ||
| resolve: { profile: true }, | ||
| }); | ||
| expect(me.profile.name).toBe("Test User"); | ||
|
|
||
| // Verify authSecretStorage is updated with provider "clerk" | ||
| const storedCredentials = await authSecretStorage.get(); | ||
| expect(storedCredentials).toEqual({ | ||
| accountID: "test-account-id", | ||
| accountSecret: "test-secret", | ||
| secretSeed: new Uint8Array([1, 2, 3]), | ||
| provider: "clerk", | ||
| }); | ||
|
|
||
| expect(signInSpy).toHaveBeenCalled(); | ||
| expect(logInSpy).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("initializeAuth", () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { isClerkAuthStateEqual } from "../types"; | ||
|
|
||
| describe("isClerkAuthStateEqual", () => { | ||
| const validCredentials = { | ||
| jazzAccountID: "account-123", | ||
| jazzAccountSecret: "secret-123", | ||
| jazzAccountSeed: [1, 2, 3], | ||
| }; | ||
|
|
||
| const differentCredentials = { | ||
| jazzAccountID: "account-456", | ||
| jazzAccountSecret: "secret-456", | ||
| jazzAccountSeed: [4, 5, 6], | ||
| }; | ||
|
|
||
| describe("both users null/undefined", () => { | ||
| it.each([ | ||
| { previous: null, next: null, description: "both null" }, | ||
| { previous: undefined, next: undefined, description: "both undefined" }, | ||
| { previous: null, next: undefined, description: "null and undefined" }, | ||
| { previous: undefined, next: null, description: "undefined and null" }, | ||
| ])("returns true when $description", ({ previous, next }) => { | ||
| expect(isClerkAuthStateEqual(previous, next)).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe("one user null, other exists", () => { | ||
| it.each([ | ||
| { | ||
| previous: null, | ||
| next: { unsafeMetadata: validCredentials }, | ||
| description: "previous null, next exists", | ||
| }, | ||
| { | ||
| previous: { unsafeMetadata: validCredentials }, | ||
| next: null, | ||
| description: "previous exists, next null", | ||
| }, | ||
| { | ||
| previous: undefined, | ||
| next: { unsafeMetadata: validCredentials }, | ||
| description: "previous undefined, next exists", | ||
| }, | ||
| { | ||
| previous: { unsafeMetadata: validCredentials }, | ||
| next: undefined, | ||
| description: "previous exists, next undefined", | ||
| }, | ||
| ])("returns false when $description", ({ previous, next }) => { | ||
| expect(isClerkAuthStateEqual(previous, next)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("same jazzAccountID", () => { | ||
| it("returns true when both users have the same jazzAccountID", () => { | ||
| const previous = { unsafeMetadata: validCredentials }; | ||
| const next = { | ||
| unsafeMetadata: { | ||
| ...validCredentials, | ||
| jazzAccountSecret: "different-secret", | ||
| }, | ||
| }; | ||
| expect(isClerkAuthStateEqual(previous, next)).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe("different jazzAccountID", () => { | ||
| it("returns false when users have different jazzAccountID", () => { | ||
| const previous = { unsafeMetadata: validCredentials }; | ||
| const next = { unsafeMetadata: differentCredentials }; | ||
| expect(isClerkAuthStateEqual(previous, next)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("neither user has valid credentials", () => { | ||
| it.each([ | ||
| { | ||
| previous: { unsafeMetadata: {} }, | ||
| next: { unsafeMetadata: {} }, | ||
| description: "both have empty metadata", | ||
| }, | ||
| { | ||
| previous: { unsafeMetadata: { someOtherField: "value" } }, | ||
| next: { unsafeMetadata: { anotherField: "value" } }, | ||
| description: "both have non-credential metadata", | ||
| }, | ||
| { | ||
| previous: { unsafeMetadata: { jazzAccountID: "123" } }, | ||
| next: { unsafeMetadata: { jazzAccountSecret: "456" } }, | ||
| description: "both have incomplete credentials", | ||
| }, | ||
| ])("returns true when $description", ({ previous, next }) => { | ||
| expect(isClerkAuthStateEqual(previous, next)).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe("one has credentials, other doesn't", () => { | ||
| it.each([ | ||
| { | ||
| previous: { unsafeMetadata: validCredentials }, | ||
| next: { unsafeMetadata: {} }, | ||
| description: "previous has credentials, next empty", | ||
| }, | ||
| { | ||
| previous: { unsafeMetadata: {} }, | ||
| next: { unsafeMetadata: validCredentials }, | ||
| description: "previous empty, next has credentials", | ||
| }, | ||
| { | ||
| previous: { unsafeMetadata: validCredentials }, | ||
| next: { unsafeMetadata: { jazzAccountID: "123" } }, | ||
| description: "previous has credentials, next has incomplete", | ||
| }, | ||
| { | ||
| previous: { unsafeMetadata: { jazzAccountSecret: "456" } }, | ||
| next: { unsafeMetadata: validCredentials }, | ||
| description: "previous has incomplete, next has credentials", | ||
| }, | ||
| ])("returns false when $description", ({ previous, next }) => { | ||
| expect(isClerkAuthStateEqual(previous, next)).toBe(false); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this needed? I thought our release process took care of updating this file
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it only updates the individual changelogs in each package.
The result is unreadable, and can't be used when publishing on discord, so I've started to manually keep track of the changes to release here.