Skip to content

fix: preserve anonymous user on magic URL upgrade#11619

Open
abishekgiri wants to merge 2 commits intoappwrite:1.9.xfrom
abishekgiri:fix-9465-anonymous-account-magic-url-upgrade
Open

fix: preserve anonymous user on magic URL upgrade#11619
abishekgiri wants to merge 2 commits intoappwrite:1.9.xfrom
abishekgiri:fix-9465-anonymous-account-magic-url-upgrade

Conversation

@abishekgiri
Copy link
Copy Markdown

Fixes #9465

Summary

Anonymous users who request a magic URL token should keep the same account when they upgrade.

Before this change, POST /account/tokens/magic-url looked up or created a user by email and could attach the token to a second account instead of the authenticated anonymous account.

This change preserves the existing anonymous user by attaching the submitted email to that user before issuing the magic URL token.

Changes

  • Added an e2e regression test for upgrading an anonymous account with magic URL
  • Updated magic URL token creation to reuse the authenticated anonymous user
  • Preserved the existing account ID through token creation and session upgrade

Testing

docker compose exec appwrite test /usr/src/code/tests/e2e/Services/Account --filter=testConvertAnonymousAccountMagicUrl

docker compose exec appwrite test /usr/src/code/tests/e2e/Services/Account --filter='/(testConvertAnonymousAccount|testConvertAnonymousAccountMagicUrl|testCreateMagicUrl|testCreateSessionWithMagicUrl)/'

Both test runs passed.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

The changes implement logic to upgrade anonymous accounts to verified email accounts via the magic URL token flow. In the account controller's magic URL endpoint, code now detects when a user document represents an anonymous account (both email and phone are null). When an anonymous user requests a magic URL token for a new email, the system updates the existing anonymous account with the email, verification status, and metadata fields, then clears the cached user document. A corresponding end-to-end test validates this upgrade pathway by creating an anonymous session, requesting a magic URL token, extracting the secret, upgrading via the magic URL endpoint, and confirming the user ID persists while the email is assigned and verified.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: preserve anonymous user on magic URL upgrade' directly and clearly summarizes the main change: ensuring anonymous users retain their account when upgrading via magic URL.
Description check ✅ Passed The description is comprehensive and related to the changeset, explaining the problem, the solution, and how the changes preserve the anonymous user through the magic URL upgrade flow.
Linked Issues check ✅ Passed The PR fully addresses issue #9465 by implementing the desired flow where anonymous users can upgrade via magic URL while preserving their existing account and session data.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the issue requirements: the magic URL token creation logic was updated to handle anonymous users, and a regression test was added to validate the fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@abishekgiri
Copy link
Copy Markdown
Author

Hi! This is my second contribution to Appwrite — happy to make any changes or adjustments if needed.

I added a regression test and updated the magic URL flow to preserve the anonymous user during upgrade. Please let me know if there are any edge cases or improvements I should address. Thanks!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 24, 2026

Greptile Summary

This PR fixes a bug where an authenticated anonymous user requesting a magic URL token would have the token attached to a freshly-created second account rather than their existing anonymous account, causing the anonymous account to be silently orphaned. The fix adds an elseif ($isAnonymousUser) branch that updates the anonymous user's document in-place with the provided email before issuing the token, preserving the original account ID throughout the magic URL flow.

Key observations:

  • The core fix (partial updateDocument on the anonymous user record) is correct and follows the existing Appwrite pattern for updating user documents.
  • The isAnonymousUser heuristic (email === null && phone === null) is validated by the anonymous user creation code.
  • An unaddressed edge case remains: when the provided email is already registered to another account, the if (!$result->isEmpty()) branch is entered first, attaching the token to the existing account and orphaning the anonymous one — no error is raised and the anonymous user loses their data silently.
  • The search field is rebuilt without the user's name, which could drop search-ability for anonymous users who had previously set a name.
  • The new e2e test covers the happy path well but lacks coverage for the already-registered-email scenario.

Confidence Score: 3/5

  • Safe to merge for the targeted scenario, but the unhandled already-registered-email edge case can silently orphan anonymous accounts in production.
  • The fix is correct and well-tested for the new-email happy path. However, the pre-existing (and now more likely to be hit) edge case where an anonymous user provides an already-registered email still orphans the anonymous account without any error or merge, which is a meaningful data-loss scenario that ideally should be resolved in the same PR or as an immediate follow-up.
  • app/controllers/api/account.php — specifically the interaction between the if (!$result->isEmpty()) branch and the anonymous-user case.

Important Files Changed

Filename Overview
app/controllers/api/account.php Adds elseif ($isAnonymousUser) branch to POST /account/tokens/magic-url so that an authenticated anonymous user's document is updated in-place with the new email rather than creating a second account. The partial updateDocument pattern used is correct and consistent with the rest of the codebase. However, the preceding if (!$result->isEmpty()) branch is entered before the anonymous-user guard when the email is already taken, silently orphaning the anonymous account and attaching the token to the existing user instead.
tests/e2e/Services/Account/AccountCustomClientTest.php Adds testConvertAnonymousAccountMagicUrl which creates an anonymous session, requests a magic URL token, confirms the session, and asserts that the upgraded account retains the original anonymous user ID and has emailVerification: true. The happy path is well covered, but there is no test for the case where the provided email is already registered to another account.

Comments Outside Diff (3)

  1. app/controllers/api/account.php, line 2144-2145 (link)

    P1 Anonymous account orphaned when email is already registered

    When an authenticated anonymous user requests a magic URL using an email address that is already registered to a different account, the if (!$result->isEmpty()) branch is entered first. The $user variable is overwritten with the existing account's attributes, and the token is subsequently attached to that existing account — not to the anonymous user.

    When the magic URL is confirmed, a session is created for the existing account. The anonymous user's session continues to exist pointing at their now-orphaned account, and no merge or cleanup occurs.

    This means an anonymous user who tries to upgrade via a magic URL with an already-registered email will silently lose their anonymous account data (e.g. memberships, preferences) with no error or warning.

    Consider either:

    1. Throwing an Exception::USER_EMAIL_ALREADY_EXISTS when an anonymous user provides an email that belongs to another account (consistent with how PATCH /account/email works on a conflict), or
    2. Merging the anonymous account into the existing one before issuing the token.

    The current PR correctly handles the case where the email is new, but leaves this edge case unaddressed.

  2. app/controllers/api/account.php, line 2156 (link)

    P2 search field may drop the user's name

    The anonymous user's search field is rebuilt as userId + email. However, if the user had previously set a name (via PATCH /account/name), that name would have been added to search at that point and would now be dropped.

    For consistency with other email-setting code paths (e.g. POST /account at line 466 uses [$userId, $email, $name]), the user's existing name should be included:

  3. tests/e2e/Services/Account/AccountCustomClientTest.php, line 3649-3665 (link)

    P2 Missing test for already-registered email edge case

    The new test only covers the happy path where the anonymous user provides a brand-new email address. There is no test for the scenario where an anonymous user requests a magic URL token using an email that is already associated with another account.

    As noted on the controller side, that path silently orphans the anonymous account. A test that asserts the expected behavior (whether that should be an error or a merge) would both document the contract and guard against regressions.

Reviews (1): Last reviewed commit: "Merge branch '1.9.x' into fix-9465-anony..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/e2e/Services/Account/AccountCustomClientTest.php (1)

3789-3792: Use structured query parsing instead of fixed-length token slicing.

substr(..., 64) is brittle if token length or email template changes. Reuse the existing link parsing helper for stability.

♻️ Suggested refactor
-        $token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64);
+        $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']);
+        $token = $tokens['secret'] ?? '';
+        $this->assertNotEmpty($token);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Services/Account/AccountCustomClientTest.php` around lines 3789 -
3792, The token extraction using substr on $lastEmail['text'] is brittle;
replace the fixed-length slice with the project's link parsing helper to
robustly parse the URL and extract the "secret" query parameter. Locate the
block using getLastEmailByAddress and swap the substr(...) line for a call to
the existing link parsing helper (use it to find the verification/reset link in
$lastEmail['text'] and then read the "secret" param), ensuring the extracted
token is used the same way thereafter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/controllers/api/account.php`:
- Around line 2173-2191: In the $isAnonymousUser branch, replicate the
identity-email uniqueness check from the else/new-user branch (the same
validation that prevents claiming an email that already exists as an OAuth
identity) before proceeding with the $dbForProject->updateDocument(...) call,
and after updating create the email messaging target the same way the new-user
flow and the /v1/account/tokens/email endpoint do (reuse the helper or method
used there) so the upgraded anonymous user has an email target; reference the
existing symbols: $isAnonymousUser, Email, $authorization->skip,
$dbForProject->updateDocument(... 'users' ...), and
$dbForProject->purgeCachedDocument to find where to insert the identity check
and the email-target creation.

In `@tests/e2e/Services/Account/AccountCustomClientTest.php`:
- Line 3759: Add a short PHPDoc block above the
testConvertAnonymousAccountMagicUrl method describing the regression scenario it
covers (e.g., converting anonymous accounts via magic URL flow), include a
one-line summary, any relevant `@covers` or `@group` tags per project conventions,
and ensure the docblock follows the project's PHPDoc style and formatting
guidelines.

---

Nitpick comments:
In `@tests/e2e/Services/Account/AccountCustomClientTest.php`:
- Around line 3789-3792: The token extraction using substr on $lastEmail['text']
is brittle; replace the fixed-length slice with the project's link parsing
helper to robustly parse the URL and extract the "secret" query parameter.
Locate the block using getLastEmailByAddress and swap the substr(...) line for a
call to the existing link parsing helper (use it to find the verification/reset
link in $lastEmail['text'] and then read the "secret" param), ensuring the
extracted token is used the same way thereafter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8a8568f8-8ec0-403b-b2d9-68911731563d

📥 Commits

Reviewing files that changed from the base of the PR and between d53cad2 and 709407e.

📒 Files selected for processing (2)
  • app/controllers/api/account.php
  • tests/e2e/Services/Account/AccountCustomClientTest.php

Comment on lines +2173 to +2191
} elseif ($isAnonymousUser) {
try {
$emailCanonical = new Email($email);
} catch (Throwable) {
$emailCanonical = null;
}

$user = $authorization->skip(fn () => $dbForProject->updateDocument('users', $user->getId(), new Document([
'email' => $email,
'emailVerification' => false,
'search' => implode(' ', [$user->getId(), $email]),
'emailCanonical' => $emailCanonical?->getCanonical(),
'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
'emailIsCorporate' => $emailCanonical?->isCorporate(),
'emailIsDisposable' => $emailCanonical?->isDisposable(),
'emailIsFree' => $emailCanonical?->isFree(),
])));

$dbForProject->purgeCachedDocument('users', $user->getId());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing identity email check and email target creation for anonymous user upgrade.

The new branch for upgrading anonymous users is missing two important pieces that exist in the else branch (new user creation):

  1. Identity email check: The else branch (lines 2203-2208) validates that the email isn't already used in an OAuth identity. Without this check, an anonymous user could claim an email that belongs to another user's OAuth identity, potentially causing account conflicts or security issues.

  2. Email target creation: When creating a new user, an email target is created for the messaging system (see the pattern in /v1/account/tokens/email endpoint). The upgraded anonymous user won't have an email target, preventing them from receiving email notifications through the messaging system.

🔧 Proposed fix
     } elseif ($isAnonymousUser) {
+        // Makes sure this email is not already used in another identity
+        $identityWithMatchingEmail = $dbForProject->findOne('identities', [
+            Query::equal('providerEmail', [$email]),
+        ]);
+        if (!$identityWithMatchingEmail->isEmpty()) {
+            throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS);
+        }
+
         try {
             $emailCanonical = new Email($email);
         } catch (Throwable) {
             $emailCanonical = null;
         }
 
         $user = $authorization->skip(fn () => $dbForProject->updateDocument('users', $user->getId(), new Document([
             'email' => $email,
             'emailVerification' => false,
             'search' => implode(' ', [$user->getId(), $email]),
             'emailCanonical' => $emailCanonical?->getCanonical(),
             'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
             'emailIsCorporate' => $emailCanonical?->isCorporate(),
             'emailIsDisposable' => $emailCanonical?->isDisposable(),
             'emailIsFree' => $emailCanonical?->isFree(),
         ])));
 
+        // Create email target for messaging
+        try {
+            $target = $authorization->skip(fn () => $dbForProject->createDocument('targets', new Document([
+                '$permissions' => [
+                    Permission::read(Role::user($user->getId())),
+                    Permission::update(Role::user($user->getId())),
+                    Permission::delete(Role::user($user->getId())),
+                ],
+                'userId' => $user->getId(),
+                'userInternalId' => $user->getSequence(),
+                'providerType' => MESSAGE_TYPE_EMAIL,
+                'identifier' => $email,
+            ])));
+            $user->setAttribute('targets', [...$user->getAttribute('targets', []), $target]);
+        } catch (Duplicate) {
+            $existingTarget = $dbForProject->findOne('targets', [
+                Query::equal('identifier', [$email]),
+            ]);
+            if (!$existingTarget->isEmpty()) {
+                $user->setAttribute('targets', $existingTarget, Document::SET_TYPE_APPEND);
+            }
+        }
+
         $dbForProject->purgeCachedDocument('users', $user->getId());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/api/account.php` around lines 2173 - 2191, In the
$isAnonymousUser branch, replicate the identity-email uniqueness check from the
else/new-user branch (the same validation that prevents claiming an email that
already exists as an OAuth identity) before proceeding with the
$dbForProject->updateDocument(...) call, and after updating create the email
messaging target the same way the new-user flow and the /v1/account/tokens/email
endpoint do (reuse the helper or method used there) so the upgraded anonymous
user has an email target; reference the existing symbols: $isAnonymousUser,
Email, $authorization->skip, $dbForProject->updateDocument(... 'users' ...), and
$dbForProject->purgeCachedDocument to find where to insert the identity check
and the email-target creation.

$this->assertEquals(401, $response['headers']['status-code']);
}

public function testConvertAnonymousAccountMagicUrl(): void
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add PHPDoc for the new test method.

Please add a short docblock describing the regression scenario covered by this test.

As per coding guidelines, "Include comprehensive PHPDoc comments".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Services/Account/AccountCustomClientTest.php` at line 3759, Add a
short PHPDoc block above the testConvertAnonymousAccountMagicUrl method
describing the regression scenario it covers (e.g., converting anonymous
accounts via magic URL flow), include a one-line summary, any relevant `@covers`
or `@group` tags per project conventions, and ensure the docblock follows the
project's PHPDoc style and formatting guidelines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug Report: Users cannot upgrade an anonymous account using magic url

1 participant