{"record":{"id":"3ae56bcf6896a993","repo":"toeverything/AFFiNE","slug":"link-expired","errorCode":"link_expired","errorMessage":"The link has expired.","messagePattern":"The link has expired\\.","errorType":"exception","errorClass":"LinkExpired","httpStatus":400,"severity":"error","filePath":"packages/backend/server/src/core/auth/resolver.ts","lineNumber":109,"sourceCode":"\n    const userSession = await this.auth.createUserSession(user.id);\n\n    return {\n      sessionToken: userSession.sessionId,\n      token: userSession.sessionId,\n      refresh: '',\n    };\n  }\n\n  @Public()\n  @Mutation(() => Boolean)\n  async changePassword(\n    @Args('token') token: string,\n    @Args('newPassword') newPassword: string,\n    @Args('userId', { type: () => String, nullable: true }) userId?: string\n  ) {\n    if (!userId) {\n      throw new LinkExpired();\n    }\n\n    // NOTE: Set & Change password are using the same token type.\n    const valid = await this.models.verificationToken.verify(\n      TokenType.ChangePassword,\n      token,\n      {\n        credential: userId,\n      }\n    );\n\n    if (!valid) {\n      throw new InvalidEmailToken();\n    }\n\n    await this.auth.changePasswordAndRevokeSessions(userId, newPassword);\n\n    return true;","sourceCodeStart":91,"sourceCodeEnd":127,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/26c515e050211269e911f7d9cfe162a26c83ed98/packages/backend/server/src/core/auth/resolver.ts#L91-L127","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Re-request the change/set-password email to obtain a fresh link containing both userId and token.","Ensure the reset landing page forwards userId (and token) from the link's query string into the mutation arguments.","Validate the link URL contains userId and token before rendering the reset form.","Avoid truncating or rewriting the email link (no URL shorteners)."],"exampleFix":"// before — userId dropped\nchangePassword({ token, newPassword })\n// after — forward userId from the email link\nchangePassword({ userId, token, newPassword })","handlingStrategy":"validation","validationCode":"import { URL } from 'url';\n\nfunction parseResetLink(link: string): { userId?: string; token?: string } {\n  try {\n    const u = new URL(link);\n    return {\n      userId: u.searchParams.get('userId') ?? undefined,\n      token: u.searchParams.get('token') ?? undefined,\n    };\n  } catch {\n    return {};\n  }\n}\n\nconst { userId, token } = parseResetLink(window.location.href);\nif (!userId || !token) {\n  showError('The reset link is incomplete. Request a new email.');\n}","typeGuard":"function isLinkExpired(err: unknown): boolean {\n  return (\n    !!err &&\n    typeof err === 'object' &&\n    (err as { code?: string }).code === 'link_expired'\n  );\n}","tryCatchPattern":"try {\n  await changePassword({ userId, token, newPassword });\n} catch (err) {\n  if (isLinkExpired(err)) {\n    showUser('This reset link is invalid or expired.');\n    redirectToRequestReset();\n    return;\n  }\n  throw err;\n}","preventionTips":["Parse the reset link and assert userId + token are present before calling the mutation.","Forward both query params unchanged from the email link.","Avoid URL shorteners or truncation on reset links.","Re-send the reset email if the link is malformed."],"tags":["auth","password","reset-link","graphql","anti-enumeration"],"backgroundTag":null,"analyzedSha":"26c515e050211269e911f7d9cfe162a26c83ed98","analyzedAt":"2026-08-12T13:15:16.447Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}