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
- 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).
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
- 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.
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
- invalid_email_token
- email_verification_required
- wrong_sign_in_credentials
- action_forbidden
- email_token_not_found
AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12).
Data as JSON: /api/errors/3ae56bcf6896a993.
Report an issue: GitHub.