Skip to content

Commit 4e0dfdb

Browse files
authored
feat(nextjs): Support auth().redirectToSignUp() (#5533)
1 parent a65532d commit 4e0dfdb

File tree

6 files changed

+179
-44
lines changed

6 files changed

+179
-44
lines changed

.changeset/spicy-lizards-take.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@clerk/nextjs': minor
3+
---
4+
5+
- Introduce `auth().redirectToSignUp()` that can be used in API routes and pages. Originally effort by [@sambarnes](https://github.com/clerk/javascript/pull/5407)
6+
7+
```ts
8+
import { clerkMiddleware } from '@clerk/nextjs/server';
9+
10+
export default clerkMiddleware(async (auth) => {
11+
const { userId, redirectToSignUp } = await auth();
12+
13+
if (!userId) {
14+
return redirectToSignUp();
15+
}
16+
});
17+
```

packages/backend/src/createRedirect.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,18 @@ export const createRedirect: CreateRedirect = params => {
9797
}
9898

9999
const accountsSignUpUrl = `${accountsBaseUrl}/sign-up`;
100-
const targetUrl = signUpUrl || accountsSignUpUrl;
100+
101+
// Allows redirection to SignInOrUp path
102+
function buildSignUpUrl(signIn: string | URL | undefined) {
103+
if (!signIn) {
104+
return;
105+
}
106+
const url = new URL(signIn, baseUrl);
107+
url.pathname = `${url.pathname}/create`;
108+
return url.toString();
109+
}
110+
111+
const targetUrl = signUpUrl || buildSignUpUrl(signInUrl) || accountsSignUpUrl;
101112

102113
if (hasPendingStatus) {
103114
return redirectToTasks(targetUrl, { returnBackUrl });

packages/nextjs/src/app-router/server/auth.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ type Auth = AuthObject & {
2525
* `auth()` on the server-side can only access redirect URLs defined via [environment variables](https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects) or [`clerkMiddleware` dynamic keys](https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys).
2626
*/
2727
redirectToSignIn: RedirectFun<ReturnType<typeof redirect>>;
28+
29+
/**
30+
* The `auth()` helper returns the `redirectToSignUp()` method, which you can use to redirect the user to the sign-up page.
31+
*
32+
* @param [returnBackUrl] {string | URL} - The URL to redirect the user back to after they sign up.
33+
*
34+
* @note
35+
* `auth()` on the server-side can only access redirect URLs defined via [environment variables](https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects) or [`clerkMiddleware` dynamic keys](https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys).
36+
*/
37+
redirectToSignUp: RedirectFun<ReturnType<typeof redirect>>;
2838
};
2939

3040
export interface AuthFn {
@@ -83,29 +93,44 @@ export const auth: AuthFn = async () => {
8393

8494
const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl');
8595

86-
const redirectToSignIn: RedirectFun<never> = (opts = {}) => {
96+
const createRedirectForRequest = (...args: Parameters<RedirectFun<never>>) => {
97+
const { returnBackUrl } = args[0] || {};
8798
const clerkRequest = createClerkRequest(request);
8899
const devBrowserToken =
89100
clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) ||
90101
clerkRequest.cookies.get(constants.Cookies.DevBrowser);
91102

92103
const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData);
93104
const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);
105+
return [
106+
createRedirect({
107+
redirectAdapter: redirect,
108+
devBrowserToken: devBrowserToken,
109+
baseUrl: clerkRequest.clerkUrl.toString(),
110+
publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY,
111+
signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL,
112+
signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL,
113+
sessionStatus: authObject.sessionStatus,
114+
}),
115+
returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(),
116+
] as const;
117+
};
118+
119+
const redirectToSignIn: RedirectFun<never> = (opts = {}) => {
120+
const [r, returnBackUrl] = createRedirectForRequest(opts);
121+
return r.redirectToSignIn({
122+
returnBackUrl,
123+
});
124+
};
94125

95-
return createRedirect({
96-
redirectAdapter: redirect,
97-
devBrowserToken: devBrowserToken,
98-
baseUrl: clerkRequest.clerkUrl.toString(),
99-
publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY,
100-
signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL,
101-
signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL,
102-
sessionStatus: authObject.sessionStatus,
103-
}).redirectToSignIn({
104-
returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl?.toString(),
126+
const redirectToSignUp: RedirectFun<never> = (opts = {}) => {
127+
const [r, returnBackUrl] = createRedirectForRequest(opts);
128+
return r.redirectToSignUp({
129+
returnBackUrl,
105130
});
106131
};
107132

108-
return Object.assign(authObject, { redirectToSignIn });
133+
return Object.assign(authObject, { redirectToSignIn, redirectToSignUp });
109134
};
110135

111136
auth.protect = async (...args: any[]) => {

packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { clerkMiddleware } from '../clerkMiddleware';
1212
import { createRouteMatcher } from '../routeMatcher';
1313
import { decryptClerkRequestData } from '../utils';
1414

15+
vi.mock('../clerkClient');
16+
1517
const publishableKey = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA';
1618
const authenticateRequestMock = vi.fn().mockResolvedValue({
1719
toAuth: () => ({
@@ -21,15 +23,6 @@ const authenticateRequestMock = vi.fn().mockResolvedValue({
2123
publishableKey,
2224
});
2325

24-
vi.mock('../clerkClient', () => {
25-
return {
26-
clerkClient: () => ({
27-
authenticateRequest: authenticateRequestMock,
28-
telemetry: { record: vi.fn() },
29-
}),
30-
};
31-
});
32-
3326
/**
3427
* Disable console warnings about config matchers
3528
*/
@@ -45,6 +38,14 @@ afterAll(() => {
4538
global.console.log = consoleLog;
4639
});
4740

41+
beforeEach(() => {
42+
vi.mocked(clerkClient).mockResolvedValue({
43+
authenticateRequest: authenticateRequestMock,
44+
// @ts-expect-error - mock
45+
telemetry: { record: vi.fn() },
46+
});
47+
});
48+
4849
// Removing this mock will cause the clerkMiddleware tests to fail due to missing publishable key
4950
// This mock SHOULD exist before the imports
5051
vi.mock(import('../constants.js'), async importOriginal => {
@@ -301,84 +302,121 @@ describe('clerkMiddleware(params)', () => {
301302
});
302303
});
303304

304-
describe('auth().redirectToSignIn()', () => {
305-
it('redirects to sign-in url when redirectToSignIn is called and the request is a page request', async () => {
305+
describe.each([
306+
{
307+
name: 'auth().redirectToSignIn()',
308+
util: 'redirectToSignIn',
309+
locationHeader: 'sign-in',
310+
} as const,
311+
{
312+
name: 'auth().redirectToSignUp()',
313+
util: 'redirectToSignUp',
314+
locationHeader: 'sign-up',
315+
} as const,
316+
])('$name', ({ util, locationHeader }) => {
317+
it(`redirects to ${locationHeader} url when ${util} is called and the request is a page request`, async () => {
306318
const req = mockRequest({
307319
url: '/protected',
308320
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
309321
appendDevBrowserCookie: true,
310322
});
311323

312324
const resp = await clerkMiddleware(async auth => {
313-
const { redirectToSignIn } = await auth();
314-
redirectToSignIn();
325+
(await auth())[util]();
315326
})(req, {} as NextFetchEvent);
316327

317328
expect(resp?.status).toEqual(307);
318-
expect(resp?.headers.get('location')).toContain('sign-in');
329+
expect(resp?.headers.get('location')).toContain(locationHeader);
319330
expect((await clerkClient()).authenticateRequest).toBeCalled();
320331
});
321332

322-
it('redirects to sign-in url when redirectToSignIn is called with the correct returnBackUrl', async () => {
333+
it(`redirects to ${locationHeader} url when redirectToSignIn is called with the correct returnBackUrl`, async () => {
323334
const req = mockRequest({
324335
url: '/protected',
325336
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
326337
appendDevBrowserCookie: true,
327338
});
328339

329340
const resp = await clerkMiddleware(async auth => {
330-
const { redirectToSignIn } = await auth();
331-
redirectToSignIn();
341+
(await auth())[util]();
332342
})(req, {} as NextFetchEvent);
333343

334-
expect(resp?.status).toEqual(307);
335344
expect(resp?.status).toEqual(307);
336345
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
337346
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toContain('/protected');
338347
expect((await clerkClient()).authenticateRequest).toBeCalled();
339348
});
340349

341-
it('redirects to sign-in url with redirect_url set to the provided returnBackUrl param', async () => {
350+
it(`redirects to ${locationHeader} url with redirect_url set to the provided returnBackUrl param`, async () => {
342351
const req = mockRequest({
343352
url: '/protected',
344353
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
345354
appendDevBrowserCookie: true,
346355
});
347356

348357
const resp = await clerkMiddleware(async auth => {
349-
const { redirectToSignIn } = await auth();
350-
redirectToSignIn({ returnBackUrl: 'https://www.clerk.com/hello' });
358+
(await auth())[util]({ returnBackUrl: 'https://www.clerk.com/hello' });
351359
})(req, {} as NextFetchEvent);
352360

353361
expect(resp?.status).toEqual(307);
354-
expect(resp?.headers.get('location')).toContain('sign-in');
362+
expect(resp?.headers.get('location')).toContain(locationHeader);
355363
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
356364
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toEqual(
357365
'https://www.clerk.com/hello',
358366
);
359367
expect((await clerkClient()).authenticateRequest).toBeCalled();
360368
});
361369

362-
it('redirects to sign-in url without a redirect_url when returnBackUrl is null', async () => {
370+
it(`redirects to ${locationHeader} url without a redirect_url when returnBackUrl is null`, async () => {
363371
const req = mockRequest({
364372
url: '/protected',
365373
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
366374
appendDevBrowserCookie: true,
367375
});
368376

369377
const resp = await clerkMiddleware(async auth => {
370-
const { redirectToSignIn } = await auth();
371-
redirectToSignIn({ returnBackUrl: null });
378+
(await auth())[util]({ returnBackUrl: null });
372379
})(req, {} as NextFetchEvent);
373380

374381
expect(resp?.status).toEqual(307);
375-
expect(resp?.headers.get('location')).toContain('sign-in');
382+
expect(resp?.headers.get('location')).toContain(locationHeader);
376383
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
377384
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toBeNull();
378385
expect((await clerkClient()).authenticateRequest).toBeCalled();
379386
});
380387
});
381388

389+
describe('auth().redirectToSignUp()', () => {
390+
it('to support signInOrUp', async () => {
391+
vi.mocked(clerkClient).mockResolvedValue({
392+
authenticateRequest: vi.fn().mockResolvedValue({
393+
toAuth: () => ({
394+
debug: (d: any) => d,
395+
}),
396+
headers: new Headers(),
397+
publishableKey,
398+
signInUrl: '/hello',
399+
}),
400+
// @ts-expect-error - mock
401+
telemetry: { record: vi.fn() },
402+
});
403+
404+
const req = mockRequest({
405+
url: '/protected',
406+
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
407+
appendDevBrowserCookie: true,
408+
});
409+
410+
const resp = await clerkMiddleware(async auth => {
411+
(await auth()).redirectToSignUp();
412+
})(req, {} as NextFetchEvent);
413+
414+
expect(resp?.status).toEqual(307);
415+
expect(resp?.headers.get('location')).toContain(`/hello/create`);
416+
expect((await clerkClient()).authenticateRequest).toBeCalled();
417+
});
418+
});
419+
382420
describe('auth.protect()', () => {
383421
it('redirects to sign-in url when protect is called, the user is signed out and the request is a page request', async () => {
384422
const req = mockRequest({

packages/nextjs/src/server/clerkMiddleware.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import {
1919
isNextjsNotFoundError,
2020
isNextjsRedirectError,
2121
isRedirectToSignInError,
22+
isRedirectToSignUpError,
2223
nextjsRedirectError,
2324
redirectToSignInError,
25+
redirectToSignUpError,
2426
} from './nextErrors';
2527
import type { AuthProtect } from './protect';
2628
import { createProtect } from './protect';
@@ -35,6 +37,7 @@ import {
3537

3638
export type ClerkMiddlewareAuthObject = AuthObject & {
3739
redirectToSignIn: RedirectFun<Response>;
40+
redirectToSignUp: RedirectFun<Response>;
3841
};
3942

4043
export interface ClerkMiddlewareAuth {
@@ -185,9 +188,13 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
185188
logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() }));
186189

187190
const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest);
191+
const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest);
188192
const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn);
189193

190-
const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { redirectToSignIn });
194+
const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, {
195+
redirectToSignIn,
196+
redirectToSignUp,
197+
});
191198
const authHandler = () => Promise.resolve(authObjWithMethods);
192199
authHandler.protect = protect;
193200

@@ -348,6 +355,15 @@ const createMiddlewareRedirectToSignIn = (
348355
};
349356
};
350357

358+
const createMiddlewareRedirectToSignUp = (
359+
clerkRequest: ClerkRequest,
360+
): ClerkMiddlewareAuthObject['redirectToSignUp'] => {
361+
return (opts = {}) => {
362+
const url = clerkRequest.clerkUrl.toString();
363+
redirectToSignUpError(url, opts.returnBackUrl);
364+
};
365+
};
366+
351367
const createMiddlewareProtect = (
352368
clerkRequest: ClerkRequest,
353369
authObject: AuthObject,
@@ -390,15 +406,21 @@ const handleControlFlowErrors = (
390406
);
391407
}
392408

393-
if (isRedirectToSignInError(e)) {
394-
return createRedirect({
409+
const isRedirectToSignIn = isRedirectToSignInError(e);
410+
const isRedirectToSignUp = isRedirectToSignUpError(e);
411+
412+
if (isRedirectToSignIn || isRedirectToSignUp) {
413+
const redirect = createRedirect({
395414
redirectAdapter,
396415
baseUrl: clerkRequest.clerkUrl,
397416
signInUrl: requestState.signInUrl,
398417
signUpUrl: requestState.signUpUrl,
399418
publishableKey: requestState.publishableKey,
400419
sessionStatus: requestState.toAuth()?.sessionStatus,
401-
}).redirectToSignIn({ returnBackUrl: e.returnBackUrl });
420+
});
421+
422+
const { returnBackUrl } = e;
423+
return redirect[isRedirectToSignIn ? 'redirectToSignIn' : 'redirectToSignUp']({ returnBackUrl });
402424
}
403425

404426
if (isNextjsRedirectError(e)) {

0 commit comments

Comments
 (0)