From 22a01dc1c604d535e4b0ca3028a0452ee453e979 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Mon, 27 Jul 2026 14:32:51 +0200 Subject: [PATCH] Fix: password reset link returns FORBIDDEN for logged-in users (#21248) (#23335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Fixes #21248. After upgrading, workspace members who open a password reset link while a token pair still exists in local storage get a generic `You do not have permission to perform this action.` (FORBIDDEN) error, blocking account recovery. ## Root cause Every GraphQL request passes through `GraphQLHydrateRequestFromTokenMiddleware` before any resolver. If a token is present it validates it; if no token is present it short-circuits and lets the request through unauthenticated. The reset flow was only ever designed for the unauthenticated case (the user is logged out, so no token exists). Two intended changes broke that assumption: - A token pair now persists in local storage at reset time (unified `accessOrWorkspaceAgnosticToken` + tokenPair moved off session cookies into local storage). - The Apollo auth link attaches `authorization: Bearer ` whenever any token pair exists, regardless of the operation. So the public `validatePasswordResetToken` / `updatePasswordViaResetToken` operations now arrive with a token that the middleware rejects, producing FORBIDDEN before the resolver runs. Note: the `PublicEndpointGuard` / `NoPermissionGuard` on these resolvers both just `return true` — they do not inspect headers and are not the gate. The middleware is. ## Fix Add a generic `skipAuthToken` operation-context flag. The auth link omits the `Authorization` header when a request sets it, staying agnostic of any specific operation or endpoint. The two public reset operations opt in at their call site in `PasswordReset.tsx`. This restores the exact unauthenticated path the flow was designed for, regardless of whether a token pair happens to sit in local storage. Nothing is reverted; all authenticated traffic is unaffected. ## Testing Ran the built frontend against a local backend, logged in so a `tokenPairState` was present in local storage, then opened a reset link and inspected the outgoing `ValidatePasswordResetToken` request: - Request headers: `accept`, `content-type`, `x-locale` only. No `authorization` header, despite a token pair being present. - With an invalid token the response is the resolver-level `Token is invalid` error (it reaches the resolver) instead of the middleware's FORBIDDEN. - With a valid token the query succeeds (`validatePasswordResetToken` returns the email + `hasPassword`) and the Set/Change Password form renders, so the recovery flow completes. Lint and typecheck pass on the changed files. --- .../src/modules/apollo/services/apollo.factory.ts | 4 ++-- packages/twenty-front/src/pages/auth/PasswordReset.tsx | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index 6957dd5e05..bce77eb18e 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -124,12 +124,12 @@ export class ApolloFactory implements ApolloManager { uri: REST_API_BASE_URL, }); - const authLink = setContext(async (_, { headers }) => { + const authLink = setContext(async (_, { headers, skipAuthToken }) => { const tokenPair = getTokenPair(); const locale = this.currentWorkspaceMember?.locale ?? i18n.locale; - if (isUndefinedOrNull(tokenPair)) { + if (isUndefinedOrNull(tokenPair) || skipAuthToken === true) { return { headers: { ...headers, diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index 97f870223e..ea0d6db6be 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -115,6 +115,7 @@ export const PasswordReset = () => { token: passwordResetToken ?? '', }, skip: !passwordResetToken || isTokenValid, + context: { skipAuthToken: true }, }, ); @@ -142,6 +143,9 @@ export const PasswordReset = () => { const [updatePasswordViaToken, { loading: isUpdatingPassword }] = useMutation( UpdatePasswordViaResetTokenDocument, + { + context: { skipAuthToken: true }, + }, ); const { signInWithCredentialsInWorkspace, signInWithCredentials } = useAuth();