Skip to content

Commit

Permalink
feat(nextjs): Improve auth().redirectToSignIn() and auth().protect() …
Browse files Browse the repository at this point in the history
…redirect mechanism

* feat(nextjs): Make auth().redirectToSignIn() in middleware work without return

* feat(nextjs): Make auth().protect() respect returnBackUrl by using redirectToSignIn internally
  • Loading branch information
nikosdouvlis committed Feb 2, 2024
1 parent aaa4570 commit 7b200af
Show file tree
Hide file tree
Showing 12 changed files with 229 additions and 74 deletions.
24 changes: 24 additions & 0 deletions .changeset/chilled-bikes-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@clerk/backend': patch
'@clerk/nextjs': patch
---

The `auth().redirectToSignIn()` helper no longer needs to be explicitly returned when called within the middleware. The following examples are now equivalent:

```js
// Before
export default clerkMiddleware(auth => {
if (protectedRoute && !auth.user) {
return auth().redirectToSignIn()
}
})

// After
export default clerkMiddleware(auth => {
if (protectedRoute && !auth.user) {
auth().redirectToSignIn()
}
})
```

Calling `auth().protect()` from a page will now automatically redirect back to the same page by setting `redirect_url` to the request url before the redirect to the sign-in URL takes place.
2 changes: 1 addition & 1 deletion integration/templates/next-app-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@types/node": "^18.17.0",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"next": "13.5.4",
"next": "13",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.1.6"
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const Attributes = {
AuthStatus: '__clerkAuthStatus',
AuthReason: '__clerkAuthReason',
AuthMessage: '__clerkAuthMessage',
ClerkUrl: '__clerkUrl',
} as const;

const Cookies = {
Expand All @@ -29,6 +30,7 @@ const Headers = {
AuthStatus: 'x-clerk-auth-status',
AuthReason: 'x-clerk-auth-reason',
AuthMessage: 'x-clerk-auth-message',
ClerkUrl: 'x-clerk-clerk-url',
EnableDebug: 'x-clerk-debug',
ClerkRedirectTo: 'x-clerk-redirect-to',
CloudFrontForwardedProto: 'cloudfront-forwarded-proto',
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/createRedirect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { errorThrower, parsePublishableKey } from './util/shared';

const buildUrl = (_baseUrl: string | URL, _targetUrl: string | URL, _returnBackUrl?: string | URL) => {
const buildUrl = (_baseUrl: string | URL, _targetUrl: string | URL, _returnBackUrl?: string | URL | null) => {
if (_baseUrl === '') {
return legacyBuildUrl(_targetUrl.toString(), _returnBackUrl?.toString());
}
Expand Down Expand Up @@ -58,7 +58,7 @@ const buildAccountsBaseUrl = (frontendApi?: string) => {
};

type RedirectAdapter<RedirectReturn> = (url: string) => RedirectReturn;
type RedirectToParams = { returnBackUrl?: string | URL };
type RedirectToParams = { returnBackUrl?: string | URL | null };
export type RedirectFun<ReturnType> = (params?: RedirectToParams) => ReturnType;

/**
Expand Down
1 change: 1 addition & 0 deletions packages/fastify/src/__snapshots__/constants.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ exports[`constants from environment variables 1`] = `
"AuthToken": "x-clerk-auth-token",
"Authorization": "authorization",
"ClerkRedirectTo": "x-clerk-redirect-to",
"ClerkUrl": "x-clerk-clerk-url",
"CloudFrontForwardedProto": "cloudfront-forwarded-proto",
"ContentType": "content-type",
"EnableDebug": "x-clerk-debug",
Expand Down
28 changes: 18 additions & 10 deletions packages/nextjs/src/app-router/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createGetAuth } from '../../server/createGetAuth';
import { authAuthHeaderMissing } from '../../server/errors';
import type { AuthProtect } from '../../server/protect';
import { createProtect } from '../../server/protect';
import { getAuthKeyFromRequest } from '../../server/utils';
import { buildRequestLike } from './utils';

type Auth = AuthObject & { protect: AuthProtect; redirectToSignIn: RedirectFun<ReturnType<typeof redirect>> };
Expand All @@ -19,16 +20,23 @@ export const auth = (): Auth => {
noAuthStatusMessage: authAuthHeaderMissing(),
})(request);

const protect = createProtect({ request, authObject, notFound, redirect });
const redirectToSignIn = createRedirect({
redirectAdapter: redirect,
baseUrl: createClerkRequest(request).clerkUrl.toString(),
// TODO: Support runtime-value configuration of these options
// via setting and reading headers from clerkMiddleware
publishableKey: PUBLISHABLE_KEY,
signInUrl: SIGN_IN_URL,
signUpUrl: SIGN_UP_URL,
}).redirectToSignIn;
const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl');

const redirectToSignIn: RedirectFun<never> = (opts = {}) => {
return createRedirect({
redirectAdapter: redirect,
baseUrl: createClerkRequest(request).clerkUrl.toString(),
// TODO: Support runtime-value configuration of these options
// via setting and reading headers from clerkMiddleware
publishableKey: PUBLISHABLE_KEY,
signInUrl: SIGN_IN_URL,
signUpUrl: SIGN_UP_URL,
}).redirectToSignIn({
returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl?.toString(),
});
};

const protect = createProtect({ request, authObject, redirectToSignIn, notFound, redirect });

return Object.assign(authObject, { protect, redirectToSignIn });
};
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/server/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => {
logger.debug(`Added ${constants.Headers.EnableDebug} on request`);
}

const result = decorateRequest(nextRequest, finalRes, requestState) || NextResponse.next();
const result = decorateRequest(clerkRequest, finalRes, requestState) || NextResponse.next();

if (requestState.headers) {
requestState.headers.forEach((value, key) => {
Expand Down
141 changes: 125 additions & 16 deletions packages/nextjs/src/server/clerkMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// There is no need to execute the complete authenticateRequest to test authMiddleware
// There is no need to execute the complete authenticateRequest to test clerkMiddleware
// This mock SHOULD exist before the import of authenticateRequest
import { AuthStatus, constants } from '@clerk/backend/internal';
import { describe, expect } from '@jest/globals';
Expand Down Expand Up @@ -36,7 +36,7 @@ afterAll(() => {
global.console.warn = consoleWarn;
});

// Removing this mock will cause the authMiddleware tests to fail due to missing publishable key
// Removing this mock will cause the clerkMiddleware tests to fail due to missing publishable key
// This mock SHOULD exist before the imports
jest.mock('./constants', () => {
return {
Expand Down Expand Up @@ -179,7 +179,7 @@ describe('authenticateRequest & handshake', () => {
});
});

describe('authMiddleware(params)', () => {
describe('clerkMiddleware(params)', () => {
it('renders route as normally when used without params', async () => {
const signInResp = await clerkMiddleware()(mockRequest({ url: '/sign-in' }), {} as NextFetchEvent);
expect(signInResp?.status).toEqual(200);
Expand Down Expand Up @@ -215,48 +215,75 @@ describe('authMiddleware(params)', () => {
});

describe('auth().redirectToSignIn()', () => {
it('redirects to sign-in url when redirectToSignIn is calle and the request is a page request', async () => {
it('redirects to sign-in url when redirectToSignIn is called and the request is a page request', async () => {
const req = mockRequest({
url: '/protected',
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
appendDevBrowserCookie: true,
});

authenticateRequestMock.mockResolvedValueOnce({
status: AuthStatus.SignedOut,
headers: new Headers(),
toAuth: () => ({ userId: null }),
const resp = await clerkMiddleware(auth => {
auth().redirectToSignIn();
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect');
expect(clerkClient.authenticateRequest).toBeCalled();
});

it('redirects to sign-in url when redirectToSignIn 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(auth => {
return auth().redirectToSignIn();
auth().redirectToSignIn();
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toContain('/protected');
expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect');
expect(clerkClient.authenticateRequest).toBeCalled();
});

it('redirects to sign-in url when redirectToSignIn is calle and the request is not a page request', async () => {
it('redirects to sign-in url with redirect_url set to the provided returnBackUrl param', async () => {
const req = mockRequest({
url: '/protected',
headers: new Headers(),
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
appendDevBrowserCookie: true,
});

authenticateRequestMock.mockResolvedValueOnce({
status: AuthStatus.SignedOut,
headers: new Headers(),
toAuth: () => ({ userId: null }),
const resp = await clerkMiddleware(auth => {
auth().redirectToSignIn({ returnBackUrl: 'https://www.clerk.com/hello' });
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toEqual(
'https://www.clerk.com/hello',
);
expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect');
expect(clerkClient.authenticateRequest).toBeCalled();
});

it('redirects to sign-in 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(auth => {
return auth().redirectToSignIn();
auth().redirectToSignIn({ returnBackUrl: null });
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toBeNull();
expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect');
expect(clerkClient.authenticateRequest).toBeCalled();
});
Expand Down Expand Up @@ -406,6 +433,88 @@ describe('authMiddleware(params)', () => {
expect(clerkClient.authenticateRequest).toBeCalled();
});
});

describe('auth().redirectToSignIn()', () => {
it('redirects to sign-in url even if called without a return statement', async () => {
const req = mockRequest({
url: '/protected',
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
appendDevBrowserCookie: true,
});

authenticateRequestMock.mockResolvedValueOnce({
status: AuthStatus.SignedOut,
headers: new Headers(),
toAuth: () => ({ userId: null }),
});

const resp = await clerkMiddleware(auth => {
auth().protect();
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect');
expect(clerkClient.authenticateRequest).toBeCalled();
});

it('redirects to unauthenticatedUrl when protect is called with the unauthenticatedUrl param, the user is signed out, and is a page request', async () => {
const req = mockRequest({
url: '/protected',
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
appendDevBrowserCookie: true,
});

authenticateRequestMock.mockResolvedValueOnce({
status: AuthStatus.SignedOut,
headers: new Headers(),
toAuth: () => ({ userId: null }),
});

const resp = await clerkMiddleware(auth => {
auth().protect({
unauthenticatedUrl: 'https://www.clerk.com/unauthenticatedUrl',
unauthorizedUrl: 'https://www.clerk.com/unauthorizedUrl',
});
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('https://www.clerk.com/unauthenticatedUrl');
expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect');
expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true');
expect(clerkClient.authenticateRequest).toBeCalled();
});

it('redirects to unauthorizedUrl when protect is called with the unauthorizedUrl param, the user is signed in but does not have permissions, and is a page request', async () => {
const req = mockRequest({
url: '/protected',
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
appendDevBrowserCookie: true,
});

authenticateRequestMock.mockResolvedValueOnce({
status: AuthStatus.SignedOut,
headers: new Headers(),
toAuth: () => ({ userId: 'userId', has: () => false }),
});

const resp = await clerkMiddleware(auth => {
auth().protect(
{ permission: 'random-permission' },
{
unauthenticatedUrl: 'https://www.clerk.com/unauthenticatedUrl',
unauthorizedUrl: 'https://www.clerk.com/unauthorizedUrl',
},
);
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('https://www.clerk.com/unauthorizedUrl');
expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect');
expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true');
expect(clerkClient.authenticateRequest).toBeCalled();
});
});
});

describe('Dev Browser JWT when redirecting to cross origin for page requests', function () {
Expand Down

0 comments on commit 7b200af

Please sign in to comment.