toeverything/AFFiNE · error · LinkExpired

link_expired

link_expired

Error message

The link has expired.

What it means

The public changePassword mutation requires a userId argument (carried in the password-reset link). If userId is absent (null/undefined/empty), LinkExpired is thrown immediately, before any token check. The message ('The link has expired.') intentionally does not distinguish a missing userId from an actually-expired token, to avoid leaking whether a userId is valid. Used by the set/change-password flow reached from email links.

Source

Thrown at packages/backend/server/src/core/auth/resolver.ts:109

    const userSession = await this.auth.createUserSession(user.id);

    return {
      sessionToken: userSession.sessionId,
      token: userSession.sessionId,
      refresh: '',
    };
  }

  @Public()
  @Mutation(() => Boolean)
  async changePassword(
    @Args('token') token: string,
    @Args('newPassword') newPassword: string,
    @Args('userId', { type: () => String, nullable: true }) userId?: string
  ) {
    if (!userId) {
      throw new LinkExpired();
    }

    // NOTE: Set & Change password are using the same token type.
    const valid = await this.models.verificationToken.verify(
      TokenType.ChangePassword,
      token,
      {
        credential: userId,
      }
    );

    if (!valid) {
      throw new InvalidEmailToken();
    }

    await this.auth.changePasswordAndRevokeSessions(userId, newPassword);

    return true;

View on GitHub (pinned to 26c515e050)

Solutions

  1. Re-request the change/set-password email to obtain a fresh link containing both userId and token.
  2. Ensure the reset landing page forwards userId (and token) from the link's query string into the mutation arguments.
  3. Validate the link URL contains userId and token before rendering the reset form.
  4. Avoid truncating or rewriting the email link (no URL shorteners).

Example fix

// before — userId dropped
changePassword({ token, newPassword })
// after — forward userId from the email link
changePassword({ userId, token, newPassword })
Defensive patterns

Strategy: validation

Validate before calling

import { URL } from 'url';

function parseResetLink(link: string): { userId?: string; token?: string } {
  try {
    const u = new URL(link);
    return {
      userId: u.searchParams.get('userId') ?? undefined,
      token: u.searchParams.get('token') ?? undefined,
    };
  } catch {
    return {};
  }
}

const { userId, token } = parseResetLink(window.location.href);
if (!userId || !token) {
  showError('The reset link is incomplete. Request a new email.');
}

Type guard

function isLinkExpired(err: unknown): boolean {
  return (
    !!err &&
    typeof err === 'object' &&
    (err as { code?: string }).code === 'link_expired'
  );
}

Try / catch

try {
  await changePassword({ userId, token, newPassword });
} catch (err) {
  if (isLinkExpired(err)) {
    showUser('This reset link is invalid or expired.');
    redirectToRequestReset();
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling the changePassword mutation without the userId argument, or with userId=null. The link the user clicked was truncated/malformed and dropped the userId query param.

Common situations: User followed a password-reset link whose userId param was stripped (URL shortener, copy-paste truncation). A client built the reset form without forwarding the userId from the link. A manually constructed request omitted userId.

Related errors


AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12). Data as JSON: /api/errors/3ae56bcf6896a993. Report an issue: GitHub.