Skip to content

Commit 8057a8b

Browse files
fix(console): /verify-email verifies through better-auth's GET route (objectui#11633) (#11651)
Fixes #11633 Clause-②: no ## What changed `VerifyEmailPage` (`apps/console/src/pages/auth/VerifyEmailPage.tsx`) sent the token as `POST /api/v1/auth/verify-email` with a JSON body. better-auth 1.7.3 declares `/verify-email` as `method: "GET"` only, so the server answered 404. Every valid token showed "Verification failed: 404" and the account stayed unverified. The page now calls `GET /api/v1/auth/verify-email?token=TOKEN`, with the token encoded through `URLSearchParams` and no `callbackURL`. Without a `callbackURL` the route answers JSON instead of a 302. The page counts only that JSON receipt (`status: true`) as success. A garbage or expired token is a 401 carrying `code` and `message`, and the page renders its error state with the server's reason. A 2xx that is not the receipt, such as an HTML page, is an error too. The page's states, copy and links are unchanged. There is no server change and no new route (triage direction, comment 5986886556). The fence holds: no export, prop, type member or i18n key is added to any `@object-ui/*` package. The diff is the body of the verify call, one new test file and the changeset. ## Live before/after: real backend, real Chromium, head `e428840` - **Backend:** objectstack `27991556` (main when read), `examples/app-showcase`, `objectstack dev --seed-admin --fresh`, with `OS_AUTH_AUDIENCE_POSTURE=open` and `OS_AUTH_AUDIENCE_SELF_REGISTRATION_PERMISSION_SET=showcase_member_default`. - **Console:** this branch's `apps/console` under Vite, proxied to that backend. - **Tokens:** each case signs up a fresh address and reads the token from its `sys_email.body_text`. The mailed link reads `ORIGIN/api/v1/auth/verify-email?token=TOKEN&callbackURL=%2F`. The expired token is a JWT for a fresh unverified user, signed with the dev secret, whose `exp` is an hour in the past. - **Before leg:** the pre-fix page (blob `2b4a836`) is put on disk under the running dev server, probed, and then restored from `HEAD`. The restore is proved by blob hash and an empty `git diff HEAD`. | case | before (pre-fix page) | after (this branch) | |---|---|---| | valid token | "Verification failed: 404"; wire `POST` → 404; sign-in afterwards 403 `EMAIL_NOT_VERIFIED` | "Email verified"; wire `GET` → 200; sign-in afterwards 200, `emailVerified: true` | | garbage token | "Verification failed: 404" (`POST` → 404) | "Verification failed" with "Invalid token" (`GET` → 401) | | expired token | "Verification failed: 404" (`POST` → 404) | "Verification failed" with "Token expired" (`GET` → 401); sign-in afterwards still 403 `EMAIL_NOT_VERIFIED` | The same server answers `POST` with 404 in both the body form and the query form, and the user stays unverified. ### Which call shape tells success from failure The probe ran `fetch` from the console origin through the proxy, with one fresh token per cell: | call shape | valid | expired | garbage | |---|---|---|---| | no `callbackURL`, `redirect` follow or manual (same answers) | 200 `application/json` with `status: true` and `user: null` | 401 JSON with `TOKEN_EXPIRED` and "Token expired" | 401 JSON with `INVALID_TOKEN` and "Invalid token" | | `callbackURL=/`, follow | 200 `text/html`, redirected to `/` | 200 `text/html`, redirected to `/?error=TOKEN_EXPIRED` | 200 `text/html`, redirected to `/?error=INVALID_TOKEN` | | `callbackURL=/`, manual | `opaqueredirect`, status 0 | same | same | | `callbackURL=/`, manual, `Accept: application/json` | `opaqueredirect`, status 0 | same | same | Only the shape without `callbackURL` tells the three cases apart from the response alone. A followed redirect is a 200 HTML page for all three, and a manual one is an opaque status 0 for all three. An `Accept` header does not change the answer, which matches the vendor source: `ctx.redirect` is thrown whenever `callbackURL` is present. ### Measured points from the dispatch - **Session cookie.** A successful verify sets no cookie: the browser context holds none afterwards, and no verify response carried `set-cookie`. `autoSignInAfterVerification` is not configured, so better-auth's default (off) applies. The success state's "You can now sign in" and its link to `/login` are therefore accurate, and the page does not route anywhere new. - **Reusable helper: none.** `@object-ui/auth`'s `AuthClient` (what `useAuth()` exposes) has no verify-email member, and adding one would add a type member, which the fence forbids. `@objectstack/client` 17.6.0 has `auth.verifyEmail`, but the public auth routes render outside `ConnectedShell`, so there is no adapter or client instance there. The method also builds `new URL(baseUrl + route + '/verify-email')` with no base. With the console's `baseUrl` (`VITE_SERVER_URL` or empty) it throws `TypeError: Invalid URL`, measured in node against the installed 17.6.0. The page therefore keeps its direct `fetch`. ## Tests (head `e428840`) - `pnpm exec vitest run apps/console/src/pages/auth/`: `Test Files 9 passed (9)`, `Tests 54 passed (54)`. - New file `apps/console/src/pages/auth/__tests__/VerifyEmailPage-11633.test.tsx` (4 tests). The stub answers like the live route: the JSON receipt for a valid token, a 401 with `code` and `message` for a garbage or expired one, 404 for any other method, and an HTML page for a request that carries `callbackURL`. The valid token contains `+`, `/` and `=`, so query encoding is pinned too. - **Ablation 1:** the pre-fix page goes on disk (blob `2b4a836`, `method: 'POST'` count 0 → 1). Result: `Tests 3 failed | 1 passed (4)`. It is restored from `HEAD` (blob `df56771`, `git diff HEAD` empty), and then `4 passed (4)`. - **Ablation 2:** the fix stays, but its receipt check `if (!res.ok || data?.status !== true)` becomes `if (!res.ok)` (anchor 1 → 0). Only "a 2xx that is not the JSON receipt (an HTML page) is not a success" turns red: `1 failed | 3 passed (4)`. After the restore, `4 passed (4)`. Ablation 1 cannot make that pin fail, because the pre-fix page never sees a 2xx from the stub. Neither ablation left a permanent test file. ## Gates (head `e428840`) - **Exit 0:** the dependency closure build (`turbo run build --filter='@object-ui/console^...'`, 34/34 tasks), `pnpm --filter @object-ui/console type-check` (the script `tsc --noEmit && tsc -b tsconfig.node.json --force` echoed) and `pnpm --filter @object-ui/console lint`. - **Lint detail:** the console's `eslint .` reports 0 errors and 221 warnings. One warning is on this page: `react-hooks/set-state-in-effect` at the missing-token branch, an unchanged line that is the same on `main`. - **Exit 0, root checks:** `check:new-line-citations` (0 new citations), `check:control-bytes`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:i18n-keys`, `check:phantom-deps`, `check:unreferenced-sources`, `scripts/check-changeset-presence.mjs` and `scripts/check-changeset-no-major.mjs`. - **Not run locally:** the repository-wide `pnpm lint` and the full `pnpm test` belong to CI. - **Governed surface:** `scripts/check-governed-queue-guard.mjs --test` on the three paths answers NOT GOVERNED. ## Acceptance notes These were observed and are not changed here. - **Three verify GETs per visit.** In the dev build each visit fires the verify call three times: React StrictMode mounts twice, and the effect runs once more when `t` changes identity (its dependencies are `token` and `t`). This is not new: the pre-fix page sent three POSTs. It is harmless for this route, because the second and third GETs answer 200 with the receipt: the route returns `status: true` for an already-verified address (measured). A change-email confirmation token would send its follow-up mail once per run. That path is reachable only by opening this page by hand with such a token, since the mailed links target the API route. - **Raw server reason in every locale.** The error state shows better-auth's English reason ("Invalid token", "Token expired"), as the pre-fix page did with the server's `message`. - **`auth.verifyEmail` throws on a relative `baseUrl`.** `@objectstack/client`'s `auth.verifyEmail` throws `TypeError: Invalid URL` when the client's `baseUrl` is relative or empty, as described above. Nothing calls it today. Owner if it gets one: none named. ## Resumption This branch was resumed from `92052a8` after a container restart. That head held the fix `d1a181c` and a merge of `main` (`531b26c`). This run reviewed both commits against the dispatch and kept them unchanged. It added one merge of `main` (`0baf86f`) and re-measured everything above on `e428840`. Run session: `https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL`. --- _Generated by [Claude Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0baf86f commit 8057a8b

3 files changed

Lines changed: 164 additions & 17 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
The console's `/verify-email?token=…` page verifies the address again (objectui#11633). It used to send the token as `POST /api/v1/auth/verify-email` with a JSON body. better-auth serves that route as GET only, so the server answered 404. Every valid token then showed "Verification failed: 404", and the account stayed unverified.
6+
7+
The page now calls `GET /api/v1/auth/verify-email?token=…`, the route the server already serves and the one the mailed link targets. It sends no `callbackURL`, so the route answers JSON instead of redirecting. The page shows the success state only for that JSON receipt (`{ status: true }`), which the route also returns when the address is already verified. A garbage or expired token gets a 401 from the server, and the page shows the error state with the server's reason. A 2xx that is not the receipt, such as an HTML page, also shows the error state. The page's states, copy and links are unchanged.
8+
9+
**Clause-②: no.** Nothing on any package entry changes. No export, prop, type member or i18n key is added or removed.

‎apps/console/src/pages/auth/VerifyEmailPage.tsx‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/**
22
* VerifyEmailPage — Console-hosted email-verification landing page.
33
*
4-
* Ported from `framework/apps/account/src/routes/verify-email.tsx`. The
5-
* user hits this URL after clicking the link in the verification email:
6-
* `?token=…` is consumed on mount via `POST /api/v1/auth/verify-email`
7-
* (better-auth's standard endpoint). `useAuth()` doesn't expose a
8-
* `verifyEmail()` so we call the REST endpoint directly.
4+
* Ported from `framework/apps/account/src/routes/verify-email.tsx`. A user
5+
* who opens `/verify-email?token=…` has `?token=` consumed on mount via
6+
* `GET /api/v1/auth/verify-email?token=…` (better-auth's standard endpoint,
7+
* served as GET only). `useAuth()` doesn't expose a `verifyEmail()` so we
8+
* call the REST endpoint directly.
99
*/
1010

1111
import { useEffect, useState } from 'react';
@@ -44,21 +44,27 @@ export function VerifyEmailPage() {
4444
let cancelled = false;
4545
(async () => {
4646
try {
47-
// better-auth exposes verify-email as a GET with `?token=` *or* a
48-
// POST with `{ token }` body. The GET variant 302-redirects on
49-
// success; the POST variant returns JSON. We use POST so the SPA
50-
// controls the post-verify UX.
51-
const res = await fetch(`${AUTH_BASE}/verify-email`, {
52-
method: 'POST',
53-
headers: { 'Content-Type': 'application/json' },
47+
// better-auth serves verify-email as `GET ?token=` only; a POST
48+
// answers 404 (objectui#11633). Sent WITHOUT `callbackURL` the route
49+
// answers JSON instead of a 302: `{ status: true, user }` once the
50+
// token verifies (again on a repeat), and a 401 `{ code, message }`
51+
// for a garbage or expired token. So the SPA keeps the post-verify
52+
// UX, and only that JSON receipt counts as success: a 2xx that is
53+
// not it (an HTML page, a followed redirect's landing) is an error.
54+
const query = new URLSearchParams({ token });
55+
const res = await fetch(`${AUTH_BASE}/verify-email?${query}`, {
56+
method: 'GET',
5457
credentials: 'include',
55-
body: JSON.stringify({ token }),
5658
});
57-
if (!res.ok) {
58-
const data = await res.json().catch(() => ({}));
59+
const data = (await res.json().catch(() => null)) as {
60+
status?: unknown;
61+
message?: unknown;
62+
} | null;
63+
if (!res.ok || data?.status !== true) {
64+
const serverMessage =
65+
!res.ok && typeof data?.message === 'string' ? data.message : '';
5966
throw new Error(
60-
(data as { message?: string })?.message ||
61-
`Verification failed: ${res.status}`,
67+
serverMessage || (res.ok ? '' : `Verification failed: ${res.status}`),
6268
);
6369
}
6470
if (!cancelled) setStatus('success');
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11633 — `/verify-email?token=…` verifies through the GET route
11+
* better-auth actually serves.
12+
*
13+
* better-auth declares `/verify-email` as `method: "GET"` only, and the
14+
* framework's auth route ledger lists only `GET /api/v1/auth/verify-email`.
15+
* The page used to POST `{ token }`, which the server answers 404, so every
16+
* valid token rendered the error state and the account stayed unverified.
17+
*
18+
* The stub answers the way the live route answers a request that carries no
19+
* `callbackURL`: a JSON receipt `{ status: true, user: null }` for a token
20+
* that verifies, and a 401 `{ code, message }` for a garbage or expired one.
21+
* Any other method is a 404, as on the server. A `callbackURL` would turn the
22+
* answer into a 302 whose followed landing is an HTML page, so the stub serves
23+
* that page to such a request, and the page must not send one.
24+
*/
25+
26+
import '@testing-library/jest-dom/vitest';
27+
import { describe, it, expect, vi, afterEach } from 'vitest';
28+
import { render, screen, cleanup } from '@testing-library/react';
29+
import { MemoryRouter } from 'react-router-dom';
30+
import { I18nProvider } from '@object-ui/i18n';
31+
import { VerifyEmailPage } from '../VerifyEmailPage';
32+
33+
afterEach(() => {
34+
cleanup();
35+
vi.unstubAllGlobals();
36+
});
37+
38+
const VALID = 'eyJhbGciOiJIUzI1NiJ9.valid+token/with=chars';
39+
const EXPIRED = 'eyJhbGciOiJIUzI1NiJ9.expired';
40+
const GARBAGE = 'not-a-jwt';
41+
42+
const json = (status: number, body: unknown) =>
43+
new Response(JSON.stringify(body), {
44+
status,
45+
headers: { 'Content-Type': 'application/json' },
46+
});
47+
48+
interface Call {
49+
url: URL;
50+
init: RequestInit | undefined;
51+
}
52+
53+
/** A server that answers `/api/v1/auth/verify-email` like better-auth does. */
54+
function verifyEmailServer(opts: { htmlOk?: boolean } = {}) {
55+
const calls: Call[] = [];
56+
const fetchStub = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
57+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
58+
const url = new URL(raw, 'http://localhost');
59+
calls.push({ url, init });
60+
const method = (init?.method ?? 'GET').toUpperCase();
61+
if (url.pathname !== '/api/v1/auth/verify-email' || method !== 'GET') {
62+
return new Response(null, { status: 404 });
63+
}
64+
if (opts.htmlOk || url.searchParams.has('callbackURL')) {
65+
return new Response('<!doctype html><html><body>console</body></html>', {
66+
status: 200,
67+
headers: { 'Content-Type': 'text/html' },
68+
});
69+
}
70+
const token = url.searchParams.get('token');
71+
if (token === VALID) return json(200, { status: true, user: null });
72+
if (token === EXPIRED) return json(401, { code: 'TOKEN_EXPIRED', message: 'Token expired' });
73+
return json(401, { code: 'INVALID_TOKEN', message: 'Invalid token' });
74+
});
75+
vi.stubGlobal('fetch', fetchStub);
76+
return calls;
77+
}
78+
79+
function renderAt(search: string) {
80+
return render(
81+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
82+
<MemoryRouter initialEntries={[`/verify-email${search}`]}>
83+
<VerifyEmailPage />
84+
</MemoryRouter>
85+
</I18nProvider>,
86+
);
87+
}
88+
89+
describe('VerifyEmailPage verifies through GET ?token= (objectui#11633)', () => {
90+
it('a valid token reaches the success state through a GET that carries it in the query', async () => {
91+
const calls = verifyEmailServer();
92+
renderAt(`?token=${encodeURIComponent(VALID)}`);
93+
94+
expect(await screen.findByText('Email verified')).toBeInTheDocument();
95+
expect(screen.queryByText('Verification failed')).toBeNull();
96+
97+
expect(calls).toHaveLength(1);
98+
const [{ url, init }] = calls;
99+
expect(url.pathname).toBe('/api/v1/auth/verify-email');
100+
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
101+
expect(init?.body).toBeUndefined();
102+
expect(url.searchParams.get('token')).toBe(VALID);
103+
// Without callbackURL the route answers its JSON receipt, not a 302.
104+
expect(url.searchParams.has('callbackURL')).toBe(false);
105+
});
106+
107+
it('a garbage token renders the error state with the server reason', async () => {
108+
verifyEmailServer();
109+
renderAt(`?token=${GARBAGE}`);
110+
111+
expect(await screen.findByText('Verification failed')).toBeInTheDocument();
112+
expect(screen.getByText('Invalid token')).toBeInTheDocument();
113+
expect(screen.queryByText('Email verified')).toBeNull();
114+
});
115+
116+
it('an expired token renders the error state', async () => {
117+
verifyEmailServer();
118+
renderAt(`?token=${EXPIRED}`);
119+
120+
expect(await screen.findByText('Verification failed')).toBeInTheDocument();
121+
expect(screen.getByText('Token expired')).toBeInTheDocument();
122+
expect(screen.queryByText('Email verified')).toBeNull();
123+
});
124+
125+
it('a 2xx that is not the JSON receipt (an HTML page) is not a success', async () => {
126+
verifyEmailServer({ htmlOk: true });
127+
renderAt(`?token=${encodeURIComponent(VALID)}`);
128+
129+
expect(await screen.findByText('Verification failed')).toBeInTheDocument();
130+
expect(screen.queryByText('Email verified')).toBeNull();
131+
});
132+
});

0 commit comments

Comments
 (0)