diff --git a/.changeset/spicy-lizards-take.md b/.changeset/spicy-lizards-take.md new file mode 100644 index 00000000000..096e5cb4731 --- /dev/null +++ b/.changeset/spicy-lizards-take.md @@ -0,0 +1,17 @@ +--- +'@clerk/nextjs': minor +--- + +- Introduce `auth().redirectToSignUp()` that can be used in API routes and pages. Originally effort by [@sambarnes](https://github.com/clerk/javascript/pull/5407) + +```ts +import { clerkMiddleware } from '@clerk/nextjs/server'; + +export default clerkMiddleware(async (auth) => { + const { userId, redirectToSignUp } = await auth(); + + if (!userId) { + return redirectToSignUp(); + } +}); +``` diff --git a/packages/backend/src/createRedirect.ts b/packages/backend/src/createRedirect.ts index ee97a00de82..90dd4ae5475 100644 --- a/packages/backend/src/createRedirect.ts +++ b/packages/backend/src/createRedirect.ts @@ -97,7 +97,18 @@ export const createRedirect: CreateRedirect = params => { } const accountsSignUpUrl = `${accountsBaseUrl}/sign-up`; - const targetUrl = signUpUrl || accountsSignUpUrl; + + // Allows redirection to SignInOrUp path + function buildSignUpUrl(signIn: string | URL | undefined) { + if (!signIn) { + return; + } + const url = new URL(signIn, baseUrl); + url.pathname = `${url.pathname}/create`; + return url.toString(); + } + + const targetUrl = signUpUrl || buildSignUpUrl(signInUrl) || accountsSignUpUrl; if (hasPendingStatus) { return redirectToTasks(targetUrl, { returnBackUrl }); diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 1fa1859b3ea..a6a4c39a632 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 { @@ -83,7 +93,8 @@ export const auth: AuthFn = async () => { const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl'); - const redirectToSignIn: RedirectFun = (opts = {}) => { + const createRedirectForRequest = (...args: Parameters>) => { + const { returnBackUrl } = args[0] || {}; const clerkRequest = createClerkRequest(request); const devBrowserToken = clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) || @@ -91,21 +102,35 @@ export const auth: AuthFn = async () => { 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, + sessionStatus: authObject.sessionStatus, + }), + returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(), + ] as const; + }; + + const redirectToSignIn: RedirectFun = (opts = {}) => { + const [r, returnBackUrl] = createRedirectForRequest(opts); + return r.redirectToSignIn({ + returnBackUrl, + }); + }; - 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, - sessionStatus: authObject.sessionStatus, - }).redirectToSignIn({ - returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl?.toString(), + const redirectToSignUp: RedirectFun = (opts = {}) => { + const [r, returnBackUrl] = createRedirectForRequest(opts); + return r.redirectToSignUp({ + returnBackUrl, }); }; - return Object.assign(authObject, { redirectToSignIn }); + 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..11c0fe1a3a1 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -12,6 +12,8 @@ import { clerkMiddleware } from '../clerkMiddleware'; import { createRouteMatcher } from '../routeMatcher'; import { decryptClerkRequestData } from '../utils'; +vi.mock('../clerkClient'); + const publishableKey = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; const authenticateRequestMock = vi.fn().mockResolvedValue({ toAuth: () => ({ @@ -21,15 +23,6 @@ const authenticateRequestMock = vi.fn().mockResolvedValue({ publishableKey, }); -vi.mock('../clerkClient', () => { - return { - clerkClient: () => ({ - authenticateRequest: authenticateRequestMock, - telemetry: { record: vi.fn() }, - }), - }; -}); - /** * Disable console warnings about config matchers */ @@ -45,6 +38,14 @@ afterAll(() => { global.console.log = consoleLog; }); +beforeEach(() => { + vi.mocked(clerkClient).mockResolvedValue({ + authenticateRequest: authenticateRequestMock, + // @ts-expect-error - mock + telemetry: { record: vi.fn() }, + }); +}); + // Removing this mock will cause the clerkMiddleware tests to fail due to missing publishable key // This mock SHOULD exist before the imports vi.mock(import('../constants.js'), async importOriginal => { @@ -301,8 +302,19 @@ describe('clerkMiddleware(params)', () => { }); }); - describe('auth().redirectToSignIn()', () => { - it('redirects to sign-in url when redirectToSignIn is called and the request is a page request', async () => { + describe.each([ + { + name: 'auth().redirectToSignIn()', + util: 'redirectToSignIn', + locationHeader: 'sign-in', + } as const, + { + name: 'auth().redirectToSignUp()', + util: 'redirectToSignUp', + locationHeader: 'sign-up', + } as const, + ])('$name', ({ util, locationHeader }) => { + it(`redirects to ${locationHeader} url when ${util} is called and the request is a page request`, async () => { const req = mockRequest({ url: '/protected', headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), @@ -310,16 +322,15 @@ describe('clerkMiddleware(params)', () => { }); const resp = await clerkMiddleware(async auth => { - const { redirectToSignIn } = await auth(); - redirectToSignIn(); + (await auth())[util](); })(req, {} as NextFetchEvent); expect(resp?.status).toEqual(307); - expect(resp?.headers.get('location')).toContain('sign-in'); + expect(resp?.headers.get('location')).toContain(locationHeader); expect((await clerkClient()).authenticateRequest).toBeCalled(); }); - it('redirects to sign-in url when redirectToSignIn is called with the correct returnBackUrl', async () => { + it(`redirects to ${locationHeader} url when redirectToSignIn is called with the correct returnBackUrl`, async () => { const req = mockRequest({ url: '/protected', headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), @@ -327,18 +338,16 @@ describe('clerkMiddleware(params)', () => { }); const resp = await clerkMiddleware(async auth => { - const { redirectToSignIn } = await auth(); - redirectToSignIn(); + (await auth())[util](); })(req, {} as NextFetchEvent); - expect(resp?.status).toEqual(307); expect(resp?.status).toEqual(307); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toContain('/protected'); expect((await clerkClient()).authenticateRequest).toBeCalled(); }); - it('redirects to sign-in url with redirect_url set to the provided returnBackUrl param', async () => { + it(`redirects to ${locationHeader} url with redirect_url set to the provided returnBackUrl param`, async () => { const req = mockRequest({ url: '/protected', headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), @@ -346,12 +355,11 @@ describe('clerkMiddleware(params)', () => { }); const resp = await clerkMiddleware(async auth => { - const { redirectToSignIn } = await auth(); - redirectToSignIn({ returnBackUrl: 'https://www.clerk.com/hello' }); + (await auth())[util]({ returnBackUrl: 'https://www.clerk.com/hello' }); })(req, {} as NextFetchEvent); expect(resp?.status).toEqual(307); - expect(resp?.headers.get('location')).toContain('sign-in'); + expect(resp?.headers.get('location')).toContain(locationHeader); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toEqual( 'https://www.clerk.com/hello', @@ -359,7 +367,7 @@ describe('clerkMiddleware(params)', () => { expect((await clerkClient()).authenticateRequest).toBeCalled(); }); - it('redirects to sign-in url without a redirect_url when returnBackUrl is null', async () => { + it(`redirects to ${locationHeader} url without a redirect_url when returnBackUrl is null`, async () => { const req = mockRequest({ url: '/protected', headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), @@ -367,18 +375,48 @@ describe('clerkMiddleware(params)', () => { }); const resp = await clerkMiddleware(async auth => { - const { redirectToSignIn } = await auth(); - redirectToSignIn({ returnBackUrl: null }); + (await auth())[util]({ returnBackUrl: null }); })(req, {} as NextFetchEvent); expect(resp?.status).toEqual(307); - expect(resp?.headers.get('location')).toContain('sign-in'); + expect(resp?.headers.get('location')).toContain(locationHeader); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toBeNull(); expect((await clerkClient()).authenticateRequest).toBeCalled(); }); }); + describe('auth().redirectToSignUp()', () => { + it('to support signInOrUp', async () => { + vi.mocked(clerkClient).mockResolvedValue({ + authenticateRequest: vi.fn().mockResolvedValue({ + toAuth: () => ({ + debug: (d: any) => d, + }), + headers: new Headers(), + publishableKey, + signInUrl: '/hello', + }), + // @ts-expect-error - mock + telemetry: { record: vi.fn() }, + }); + + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + const resp = await clerkMiddleware(async auth => { + (await auth()).redirectToSignUp(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain(`/hello/create`); + 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 9b093db3802..929eaf3c3e9 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -17,8 +17,10 @@ import { isNextjsNotFoundError, isNextjsRedirectError, isRedirectToSignInError, + isRedirectToSignUpError, nextjsRedirectError, redirectToSignInError, + redirectToSignUpError, } from './nextErrors'; import type { AuthProtect } from './protect'; import { createProtect } from './protect'; @@ -33,6 +35,7 @@ import { export type ClerkMiddlewareAuthObject = AuthObject & { redirectToSignIn: RedirectFun; + redirectToSignUp: RedirectFun; }; export interface ClerkMiddlewareAuth { @@ -162,9 +165,13 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl 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; @@ -303,6 +310,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, @@ -345,15 +361,21 @@ const handleControlFlowErrors = ( ); } - if (isRedirectToSignInError(e)) { - return createRedirect({ + const isRedirectToSignIn = isRedirectToSignInError(e); + const isRedirectToSignUp = isRedirectToSignUpError(e); + + if (isRedirectToSignIn || isRedirectToSignUp) { + const redirect = createRedirect({ redirectAdapter, baseUrl: clerkRequest.clerkUrl, signInUrl: requestState.signInUrl, signUpUrl: requestState.signUpUrl, publishableKey: requestState.publishableKey, sessionStatus: requestState.toAuth()?.sessionStatus, - }).redirectToSignIn({ returnBackUrl: e.returnBackUrl }); + }); + + const { returnBackUrl } = e; + return redirect[isRedirectToSignIn ? 'redirectToSignIn' : 'redirectToSignUp']({ returnBackUrl }); } if (isNextjsRedirectError(e)) { diff --git a/packages/nextjs/src/server/nextErrors.ts b/packages/nextjs/src/server/nextErrors.ts index 034132c17db..d5277a7104f 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', }; /** @@ -92,10 +93,21 @@ function nextjsRedirectError( throw error; } +function buildReturnBackUrl(url: string, returnBackUrl?: string | URL | null): string | URL { + return returnBackUrl === null ? '' : returnBackUrl || url; +} + function redirectToSignInError(url: string, returnBackUrl?: string | URL | null): never { nextjsRedirectError(url, { clerk_digest: CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN, - returnBackUrl: returnBackUrl === null ? '' : returnBackUrl || url, + returnBackUrl: buildReturnBackUrl(url, returnBackUrl), + }); +} + +function redirectToSignUpError(url: string, returnBackUrl?: string | URL | null): never { + nextjsRedirectError(url, { + clerk_digest: CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_UP, + returnBackUrl: buildReturnBackUrl(url, returnBackUrl), }); } @@ -135,11 +147,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, };