Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/10893-signup-carries-verification-callback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@object-ui/auth': minor
'@object-ui/console': patch
---

fix(auth, console): a registration started from an invitation link comes back to the invitation after email verification

better-auth builds the verification mail's link server-side from the
`/sign-up/email` request's `callbackURL`, defaulting it to `/`. The console kept
`?redirect=/accept-invitation/ID` from the login page through `/register` to the
"check your inbox" screen, but `createAuthClient().signUp` never sent a
`callbackURL`, so the mail read `callbackURL=/` and the invitee verified onto the
workspace picker ("Create workspace") instead of the invitation.

`@object-ui/auth` (additive):

- `SignUpData.callbackURL` is forwarded verbatim to `/sign-up/email`. Absent, the
key stays off the wire and the server default is untouched.
- `useAuth().signUp(name, email, password, callbackURL?)` takes it as an optional
fourth argument.
- `RegisterForm` gains a `verificationCallbackURL` prop, forwarded to `signUp`.

`@object-ui/console`:

- `RegisterPage` passes a safe `?redirect=` as the verification callback, and the
verify-email prompt's "Resend" sends the same value. Before, Resend sent the
bare router path, which the server redirects to at the ORIGIN root, outside
the `/_console` mount.
- Both resolve the route through the new `withConsoleBaseRootRelative`. The server
never sees `<base href>`, and better-auth refuses a document-relative `./…`
callback — what `withConsoleBase` answers in the embedded build — with
`403 INVALID_CALLBACK_URL`, failing the whole sign-up.
13 changes: 11 additions & 2 deletions apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* - Bounces to `/login` if `emailPassword.disableSignUp === true`
* (defense-in-depth; the server-side gate is the source of truth).
* - Routes to `/verify-email-prompt` when the server requires email
* verification before sign-in.
* verification before sign-in, and carries `?redirect=` into the
* verification mail's link so it survives the inbox (objectui#10893).
* - Replays an `/oauth2/authorize` query string when the user landed
* here mid-SSO so the IdP can continue the flow post-signup.
*/
Expand All @@ -22,7 +23,7 @@ import { AuthLayout } from './AuthLayout';
import { followOauthAuthorize } from './followAuthorize';
// Was a second module-private copy of LoginPage's helper; both now share one
// implementation — objectui#4181. Behaviour here is unchanged.
import { withConsoleBase } from '../../utils/consoleBase';
import { withConsoleBase, withConsoleBaseRootRelative } from '../../utils/consoleBase';

function isSafeRedirect(target: string | null): target is string {
return !!target && target.startsWith('/') && !target.startsWith('//');
Expand Down Expand Up @@ -141,6 +142,13 @@ export function RegisterPage() {
}

const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login';
// objectui#10893 — when the server gates sign-in on email verification, the
// mail's link is the only road back, so it must carry `?redirect=` (e.g. the
// invitation the user registered from). Only a safe in-app target is
// forwarded; anything else would be refused by the server and fail sign-up.
const verificationCallbackURL = isSafeRedirect(redirect)
? withConsoleBaseRootRelative(redirect)
: undefined;

return (
<AuthLayout formWidth="md">
Expand All @@ -151,6 +159,7 @@ export function RegisterPage() {
defaultValue: 'Create your account to start building.',
})}
loginUrl={loginUrl}
verificationCallbackURL={verificationCallbackURL}
linkComponent={RouterLink}
errorMessages={{
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: t('auth.register.errors.userExists', {
Expand Down
12 changes: 11 additions & 1 deletion apps/console/src/pages/auth/VerifyEmailPromptPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
CardTitle,
} from '@object-ui/components';
import { AuthLayout } from './AuthLayout';
import { withConsoleBaseRootRelative } from '../../utils/consoleBase';

export function VerifyEmailPromptPage() {
const { t } = useObjectTranslation();
Expand Down Expand Up @@ -56,7 +57,16 @@ export function VerifyEmailPromptPage() {
setResending(true);
setResendError(null);
try {
await sendVerificationEmail(email, redirect || '/');
// objectui#10893 — `redirect` is a router path (basename-stripped). The
// server redirects to this value verbatim after verification, so it
// must be resolved against the console mount first: the bare route
// landed at the ORIGIN root, outside `/_console`. No safe target means
// no callbackURL, and the server default applies, as it did before.
const callbackURL =
redirect.startsWith('/') && !redirect.startsWith('//')
? withConsoleBaseRootRelative(redirect)
: undefined;
await sendVerificationEmail(email, callbackURL);
setResent(true);
toast.success(
t('auth.verifyEmail.resentSuccess', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* objectui#10893 — an invitee who registers from an invitation link must be
* brought back to the invitation by the verification mail.
*
* The console kept `?redirect=/accept-invitation/ID` from the login page through
* `/register` to `/verify-email-prompt`, but the verification mail is built
* server-side from the sign-up request's `callbackURL`, which nothing sent: the
* link read `callbackURL=/` and the invitee verified onto the workspace picker.
*
* Two console seams feed that value:
*
* - `RegisterPage` → `RegisterForm.verificationCallbackURL` → `signUp`'s 4th
* argument (the first mail);
* - `VerifyEmailPromptPage` → `sendVerificationEmail`'s 2nd argument (the
* "Resend" mail), which forwarded the bare router path and so pointed at the
* ORIGIN root, outside the console mount.
*
* Both must hand the server a ROOT-relative url inside the mount. The server
* never sees `<base href>`; better-auth refuses a document-relative `./…` with
* `403 INVALID_CALLBACK_URL`, failing the whole sign-up. So the embedded mount
* (whose `withConsoleBase` answer is `./…`) is the case that matters most.
*
* Nothing in `@object-ui/auth` is replaced: a real `AuthProvider` over a real
* `createAuthClient` runs against a stub server, and every assertion reads the
* REQUEST BODY the server would receive — the value better-auth writes into the
* mail's link. So the page, the shipped `RegisterForm`, `useAuth`, the provider
* and the client are all on the path; dropping the value at any hop turns these
* red.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { I18nProvider } from '@object-ui/i18n';
import { AuthProvider, createAuthClient } from '@object-ui/auth';

vi.mock('sonner', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, toast: { success: vi.fn(), error: vi.fn() } };
});

const { RegisterPage } = await import('../RegisterPage');
const { VerifyEmailPromptPage } = await import('../VerifyEmailPromptPage');

/** The three configurations the console ships in (see authExitBasename.test). */
const MOUNTS = {
standalone: { href: null, baseUrl: '/', basename: '/', prefix: '' },
embedded: { href: '/_console/', baseUrl: './', basename: '/_console', prefix: '/_console' },
pinned: { href: '/_console/', baseUrl: '/_console/', basename: '/_console', prefix: '/_console' },
} as const;
type MountName = keyof typeof MOUNTS;

let baseEl: HTMLBaseElement | null = null;

function mountConsole(name: MountName, at: string) {
const mount = MOUNTS[name];
baseEl?.remove();
baseEl = null;
if (mount.href) {
baseEl = document.createElement('base');
baseEl.setAttribute('href', mount.href);
document.head.appendChild(baseEl);
}
vi.stubEnv('BASE_URL', mount.baseUrl);
window.history.replaceState({}, '', `${mount.prefix}${at}`);
return mount;
}

const AUTH_URL = 'http://localhost/api/v1/auth';
type WireBodies = Record<'signUp' | 'resend', Array<Record<string, unknown>>>;
let wire: WireBodies;

/**
* A signed-out visitor on a server that gates sign-in on email verification:
* no session, `/sign-up/email` answers with a null token.
*/
function verificationGatedServer(): typeof fetch {
wire = { signUp: [], resend: [] };
const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
return (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/sign-up/email')) {
wire.signUp.push(JSON.parse(String(init?.body)));
return json({ user: { id: 'u_new', name: 'Wang Wei', email: 'wangwei@example.com' }, token: null });
}
if (url.includes('/send-verification-email')) {
wire.resend.push(JSON.parse(String(init?.body)));
return json({ status: true });
}
if (url.endsWith('/config')) return json({});
return json(null);
}) as typeof fetch;
}

function renderAt(basename: string, ui: React.ReactElement) {
const client = createAuthClient({ baseURL: AUTH_URL, fetchFn: verificationGatedServer() });
return render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<AuthProvider authUrl={AUTH_URL} client={client}>
<BrowserRouter basename={basename}>{ui}</BrowserRouter>
</AuthProvider>
</I18nProvider>,
);
}

/** What the server would be asked to redirect to after verification. */
async function signUpCallback(name: MountName, search: string): Promise<unknown> {
const mount = mountConsole(name, `/register${search}`);
renderAt(mount.basename, <RegisterPage />);

await userEvent.type(await screen.findByLabelText('Name'), 'Wang Wei');
await userEvent.type(screen.getByLabelText('Email'), 'wangwei@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'hunter2hunter2');
await userEvent.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Create Account' }));

await waitFor(() => expect(wire.signUp).toHaveLength(1));
expect(wire.signUp[0]).toMatchObject({ name: 'Wang Wei', email: 'wangwei@example.com' });
return wire.signUp[0].callbackURL;
}

/** A url better-auth accepts as a callbackURL without a trusted-origin list. */
function isRootRelative(value: unknown): boolean {
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//');
}

const INVITE = '?redirect=%2Faccept-invitation%2Finv_1';

beforeEach(() => {
vi.spyOn(window.location, 'assign').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
cleanup();
baseEl?.remove();
baseEl = null;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

describe('RegisterPage — the verification mail carries ?redirect= (objectui#10893)', () => {
it('embedded console: the invitation, root-relative inside the mount', async () => {
const callback = await signUpCallback('embedded', INVITE);
expect(callback).toBe('/_console/accept-invitation/inv_1');
expect(isRootRelative(callback)).toBe(true);
});

it('pinned-base console: the invitation, inside the mount', async () => {
expect(await signUpCallback('pinned', INVITE)).toBe('/_console/accept-invitation/inv_1');
});

it('default `/` mount: the invitation route as-is', async () => {
expect(await signUpCallback('standalone', INVITE)).toBe('/accept-invitation/inv_1');
});

it('no ?redirect=: no callbackURL on the wire, so the server default is untouched', async () => {
expect(await signUpCallback('embedded', '')).toBeUndefined();
expect(Object.keys(wire.signUp[0])).not.toContain('callbackURL');
});

it('an off-site ?redirect= is not forwarded (the server would refuse it and fail sign-up)', async () => {
expect(await signUpCallback('embedded', '?redirect=%2F%2Fevil.example%2Fx')).toBeUndefined();
});

it('still routes to the inbox prompt with the redirect kept', async () => {
await signUpCallback('embedded', INVITE);
await waitFor(() => expect(window.location.pathname).toBe('/_console/verify-email-prompt'));
const sp = new URLSearchParams(window.location.search);
expect(sp.get('email')).toBe('wangwei@example.com');
expect(sp.get('redirect')).toBe('/accept-invitation/inv_1');
});
});

describe('VerifyEmailPromptPage — the resent mail carries ?redirect= too (objectui#10893)', () => {
async function resendCallback(name: MountName, search: string): Promise<[unknown, unknown]> {
const mount = mountConsole(name, `/verify-email-prompt${search}`);
renderAt(mount.basename, <VerifyEmailPromptPage />);
await userEvent.click(await screen.findByRole('button', { name: /Resend verification email/ }));
await waitFor(() => expect(wire.resend).toHaveLength(1));
return [wire.resend[0].email, wire.resend[0].callbackURL];
}

it('embedded console: resolves the router path into the mount', async () => {
const [email, callback] = await resendCallback('embedded', `?email=wangwei%40example.com&${INVITE.slice(1)}`);
expect(email).toBe('wangwei@example.com');
// The pre-fix value was the bare router path '/accept-invitation/inv_1',
// which the server redirected to at the ORIGIN root, outside `/_console`.
expect(callback).toBe('/_console/accept-invitation/inv_1');
});

it('default `/` mount: the invitation route as-is', async () => {
const [, callback] = await resendCallback('standalone', `?email=wangwei%40example.com&${INVITE.slice(1)}`);
expect(callback).toBe('/accept-invitation/inv_1');
});

it('no ?redirect=: no callbackURL on the wire, so the server default applies', async () => {
const [, callback] = await resendCallback('embedded', '?email=wangwei%40example.com');
expect(callback).toBeUndefined();
expect(Object.keys(wire.resend[0])).not.toContain('callbackURL');
});
});
43 changes: 42 additions & 1 deletion apps/console/src/utils/consoleBase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { withConsoleBase } from './consoleBase';
import { withConsoleBase, withConsoleBaseRootRelative } from './consoleBase';

let baseEl: HTMLBaseElement | null = null;

Expand Down Expand Up @@ -108,3 +108,44 @@ describe('withConsoleBase', () => {
expect(withConsoleBase('organizations')).toBe('/_console/organizations');
});
});

/**
* `withConsoleBaseRootRelative` — the same route as a server-side redirect
* target (objectui#10893). The server never sees `<base href>`, and
* better-auth refuses a document-relative `./…` callbackURL with
* `403 INVALID_CALLBACK_URL` (failing the whole sign-up), so every mount must
* yield a ROOT-relative path — and it must be the path a full-page navigation
* to the same route lands on.
*/
describe('withConsoleBaseRootRelative', () => {
const ROUTE = '/accept-invitation/inv_1';

it('THE HAZARD it exists for: the embedded build\'s withConsoleBase answer is document-relative', () => {
mountConsole('/_console/', './');
expect(withConsoleBase(ROUTE)).toBe('./accept-invitation/inv_1');
});

it.each([
['the shipped embeddable build', '/_console/', './', '/_console/accept-invitation/inv_1'],
['a pinned absolute base', '/_console/', '/_console/', '/_console/accept-invitation/inv_1'],
['the default `/` mount', null, '/', '/accept-invitation/inv_1'],
])('%s: root-relative, and where a navigation to the route lands', (_name, href, baseUrl, expected) => {
mountConsole(href, baseUrl);
const target = withConsoleBaseRootRelative(ROUTE);
expect(target).toBe(expected);
expect(target.startsWith('/') && !target.startsWith('//')).toBe(true);
expect(target).toBe(lands(withConsoleBase(ROUTE)));
});

it('keeps the query and hash of the route', () => {
mountConsole('/_console/', './');
expect(withConsoleBaseRootRelative('/settings?tab=members#invites')).toBe(
'/_console/settings?tab=members#invites',
);
});

it('leaves a target that names its own absolute SPA mount untouched', () => {
mountConsole('/_console/', './');
expect(withConsoleBaseRootRelative('/_studio/apps')).toBe('/_studio/apps');
});
});
19 changes: 19 additions & 0 deletions apps/console/src/utils/consoleBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,22 @@ export function withConsoleBase(path: string): string {
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
return base + (path.startsWith('/') ? path : `/${path}`);
}

/**
* A console route as a ROOT-RELATIVE url (`/_console/accept-invitation/ID`) —
* the form a SERVER-side redirect target needs, such as the `callbackURL`
* better-auth writes into the email-verification link (objectui#10893).
*
* `withConsoleBase` is not enough there. In the shipped embeddable build it
* returns a document-relative `./…`, which only a browser resolves (against
* `<base href>`); the server never sees that base. better-auth accepts only a
* root-relative path or a trusted absolute URL and answers `./…` with
* `403 INVALID_CALLBACK_URL` — failing the whole sign-up, not just the
* redirect. So this resolves `withConsoleBase`'s answer exactly the way
* `location.assign` would, then keeps the path, query and hash. Every mount
* therefore agrees with where a full-page navigation to the same route lands.
*/
export function withConsoleBaseRootRelative(path: string): string {
const url = new URL(withConsoleBase(path), document.baseURI);
return `${url.pathname}${url.search}${url.hash}`;
}
Loading
Loading