diff --git a/.changeset/giant-items-glow.md b/.changeset/giant-items-glow.md new file mode 100644 index 00000000000..74f4f16576e --- /dev/null +++ b/.changeset/giant-items-glow.md @@ -0,0 +1,18 @@ +--- +'@clerk/nextjs': patch +--- + +- Introduce `auth().redirectToSignUp()` that can be used in API routes and pages, eg +```ts +import { auth } from '@clerk/nextjs/server'; + +export const Layout = ({ children }) => { + const { userId } = auth(); + + if (!userId) { + return auth().redirectToSignUp(); + } + + return <>{children}; +}; +``` diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index eddee32a647..913ca6c0013 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -25,6 +25,16 @@ type Auth = AuthObject & { * `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). */ redirectToSignIn: RedirectFun>; + + /** + * The `auth()` helper returns the `redirectToSignUp()` method, which you can use to redirect the user to the sign-up page. + * + * @param [returnBackUrl] {string | URL} - The URL to redirect the user back to after they sign up. + * + * @note + * `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). + */ + redirectToSignUp: RedirectFun>; }; export interface AuthFn { @@ -104,7 +114,28 @@ export const auth: AuthFn = async () => { }); }; - return Object.assign(authObject, { redirectToSignIn }); + const redirectToSignUp: RedirectFun = (opts = {}) => { + const clerkRequest = createClerkRequest(request); + const devBrowserToken = + clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) || + clerkRequest.cookies.get(constants.Cookies.DevBrowser); + + const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData); + const decryptedRequestData = decryptClerkRequestData(encryptedRequestData); + + return createRedirect({ + redirectAdapter: redirect, + devBrowserToken: devBrowserToken, + baseUrl: clerkRequest.clerkUrl.toString(), + publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY, + signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL, + signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL, + }).redirectToSignUp({ + returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl?.toString(), + }); + }; + + return Object.assign(authObject, { redirectToSignIn, redirectToSignUp }); }; auth.protect = async (...args: any[]) => { diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 253f4772f5d..cbb7901bae8 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -379,6 +379,81 @@ describe('clerkMiddleware(params)', () => { }); }); + describe('auth().redirectToSignUp()', () => { + it('redirects to sign-up url when redirectToSignUp is called and the request is a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + const resp = await clerkMiddleware(async auth => { + const { redirectToSignUp } = await auth(); + redirectToSignUp(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('sign-up'); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('redirects to sign-up url when redirectToSignUp is called with the correct returnBackUrl', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + const resp = await clerkMiddleware(async auth => { + const { redirectToSignUp } = await auth(); + redirectToSignUp(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('sign-up'); + expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toContain('/protected'); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('redirects to sign-up url with redirect_url set to the provided returnBackUrl param', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + const resp = await clerkMiddleware(async auth => { + const { redirectToSignUp } = await auth(); + redirectToSignUp({ returnBackUrl: 'https://www.clerk.com/hello' }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('sign-up'); + expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toEqual( + 'https://www.clerk.com/hello', + ); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('redirects to sign-up url without a redirect_url when returnBackUrl is null', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + const resp = await clerkMiddleware(async auth => { + const { redirectToSignUp } = await auth(); + redirectToSignUp({ returnBackUrl: null }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('sign-up'); + expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toBeNull(); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + }); + describe('auth.protect()', () => { it('redirects to sign-in url when protect is called, the user is signed out and the request is a page request', async () => { const req = mockRequest({ diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 53bdb9f82e1..2474b86bb92 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -18,8 +18,10 @@ import { isNextjsNotFoundError, isNextjsRedirectError, isRedirectToSignInError, + isRedirectToSignUpError, nextjsRedirectError, redirectToSignInError, + redirectToSignUpError, } from './nextErrors'; import type { AuthProtect } from './protect'; import { createProtect } from './protect'; @@ -34,6 +36,7 @@ import { export type ClerkMiddlewareAuthObject = AuthObject & { redirectToSignIn: RedirectFun; + redirectToSignUp: RedirectFun; }; export interface ClerkMiddlewareAuth { @@ -172,9 +175,13 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() })); const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest); + const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest); const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); - const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { redirectToSignIn }); + const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { + redirectToSignIn, + redirectToSignUp, + }); const authHandler = () => Promise.resolve(authObjWithMethods); authHandler.protect = protect; @@ -311,6 +318,15 @@ const createMiddlewareRedirectToSignIn = ( }; }; +const createMiddlewareRedirectToSignUp = ( + clerkRequest: ClerkRequest, +): ClerkMiddlewareAuthObject['redirectToSignUp'] => { + return (opts = {}) => { + const url = clerkRequest.clerkUrl.toString(); + redirectToSignUpError(url, opts.returnBackUrl); + }; +}; + const createMiddlewareProtect = ( clerkRequest: ClerkRequest, authObject: AuthObject, @@ -363,6 +379,16 @@ const handleControlFlowErrors = ( }).redirectToSignIn({ returnBackUrl: e.returnBackUrl }); } + if (isRedirectToSignUpError(e)) { + return createRedirect({ + redirectAdapter, + baseUrl: clerkRequest.clerkUrl, + signInUrl: requestState.signInUrl, + signUpUrl: requestState.signUpUrl, + publishableKey: requestState.publishableKey, + }).redirectToSignUp({ returnBackUrl: e.returnBackUrl }); + } + if (isNextjsRedirectError(e)) { return redirectAdapter(e.redirectUrl); } diff --git a/packages/nextjs/src/server/nextErrors.ts b/packages/nextjs/src/server/nextErrors.ts index 034132c17db..a88bb904b3a 100644 --- a/packages/nextjs/src/server/nextErrors.ts +++ b/packages/nextjs/src/server/nextErrors.ts @@ -4,6 +4,7 @@ const CONTROL_FLOW_ERROR = { REDIRECT_TO_URL: 'CLERK_PROTECT_REDIRECT_TO_URL', REDIRECT_TO_SIGN_IN: 'CLERK_PROTECT_REDIRECT_TO_SIGN_IN', + REDIRECT_TO_SIGN_UP: 'CLERK_PROTECT_REDIRECT_TO_SIGN_UP', }; /** @@ -99,6 +100,13 @@ function redirectToSignInError(url: string, returnBackUrl?: string | URL | null) }); } +function redirectToSignUpError(url: string, returnBackUrl?: string | URL | null): never { + nextjsRedirectError(url, { + clerk_digest: CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_UP, + returnBackUrl: returnBackUrl === null ? '' : returnBackUrl || url, + }); +} + /** * Checks an error to determine if it's an error generated by the * `redirect(url)` helper. @@ -135,11 +143,21 @@ function isRedirectToSignInError(error: unknown): error is RedirectError<{ retur return false; } +function isRedirectToSignUpError(error: unknown): error is RedirectError<{ returnBackUrl: string | URL }> { + if (isNextjsRedirectError(error) && 'clerk_digest' in error) { + return error.clerk_digest === CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_UP; + } + + return false; +} + export { isNextjsNotFoundError, isLegacyNextjsNotFoundError, redirectToSignInError, + redirectToSignUpError, nextjsRedirectError, isNextjsRedirectError, isRedirectToSignInError, + isRedirectToSignUpError, };