Skip to content

Commit

Permalink
feat(adapter-nextjs): surface redirect error and sign-in timeout error
Browse files Browse the repository at this point in the history
  • Loading branch information
HuiSF committed Jan 10, 2025
1 parent 778c2b6 commit 91a0248
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
exchangeAuthNTokens,
getCookieValuesFromRequest,
getRedirectOrDefault,
resolveCodeAndStateFromUrl,
parseSignInCallbackUrl,
resolveRedirectSignInUrl,
} from '../../../src/auth/utils';
import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types';
Expand All @@ -25,6 +25,11 @@ import {
STATE_COOKIE_NAME,
} from '../../../src/auth/constant';

import {
ERROR_CLIENT_COOKIE_COMBINATIONS,
ERROR_URL_PARAMS_COMBINATIONS,
} from './signInCallbackErrorCombinations';

jest.mock('../../../src/auth/utils');

const mockAppendSetCookieHeaders = jest.mocked(appendSetCookieHeaders);
Expand All @@ -43,7 +48,7 @@ const mockCreateTokenCookiesSetOptions = jest.mocked(
);
const mockExchangeAuthNTokens = jest.mocked(exchangeAuthNTokens);
const mockGetCookieValuesFromRequest = jest.mocked(getCookieValuesFromRequest);
const mockResolveCodeAndStateFromUrl = jest.mocked(resolveCodeAndStateFromUrl);
const mockParseSignInCallbackUrl = jest.mocked(parseSignInCallbackUrl);
const mockResolveRedirectSignInUrl = jest.mocked(resolveRedirectSignInUrl);
const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault);

Expand Down Expand Up @@ -72,19 +77,25 @@ describe('handleSignInCallbackRequest', () => {
mockCreateTokenCookiesSetOptions.mockClear();
mockExchangeAuthNTokens.mockClear();
mockGetCookieValuesFromRequest.mockClear();
mockResolveCodeAndStateFromUrl.mockClear();
mockParseSignInCallbackUrl.mockClear();
mockResolveRedirectSignInUrl.mockClear();
});

test.each([
[null, 'state'],
['state', null],
])(
'returns a 400 response when request.url contains query params: code=%s, state=%s',
async (code, state) => {
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
test.each(ERROR_URL_PARAMS_COMBINATIONS)(
'returns a $expectedStatus response when request.url contains query params: code=$code, state=$state, error=$error, error_description=$errorDescription',
async ({
code,
state,
error,
errorDescription,
expectedStatus,
expectedRedirect,
}) => {
mockParseSignInCallbackUrl.mockReturnValueOnce({
code,
state,
error,
errorDescription,
});
const url = 'https://example.com/api/auth/sign-in-callback';
const request = new NextRequest(new URL(url));
Expand All @@ -98,33 +109,30 @@ describe('handleSignInCallbackRequest', () => {
origin: mockOrigin,
});

expect(response.status).toBe(400);
expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url);
expect(response.status).toBe(expectedStatus);
expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url);

if (expectedStatus === 302) {
expect(response.headers.get('Location')).toBe(expectedRedirect);
}
},
);

test.each([
['client state cookie is missing', undefined, 'state', 'pkce'],
[
'client cookie state a different value from the state query parameter',
'state_different',
'state',
'pkce',
],
['client pkce cookie is missing', 'state', 'state', undefined],
])(
`returns a 400 response when %s`,
async (_, clientState, state, clientPkce) => {
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
test.each(ERROR_CLIENT_COOKIE_COMBINATIONS)(
`returns a $expectedStatus response when client cookies are: state=$state, pkce=$pkce and expected state value is 'state_b'`,
async ({ state, pkce, expectedStatus, expectedRedirect }) => {
mockParseSignInCallbackUrl.mockReturnValueOnce({
code: 'not_important_for_this_test',
state,
state: 'not_important_for_this_test',
error: null,
errorDescription: null,
});
mockGetCookieValuesFromRequest.mockReturnValueOnce({
[STATE_COOKIE_NAME]: clientState,
[PKCE_COOKIE_NAME]: clientPkce,
[STATE_COOKIE_NAME]: state,
[PKCE_COOKIE_NAME]: pkce,
});

const url = `https://example.com/api/auth/sign-in-callback?state=${state}&code=not_important_for_this_test`;
const url = `https://example.com/api/auth/sign-in-callback?state=state_b&code=not_important_for_this_test`;
const request = new NextRequest(new URL(url));

const response = await handleSignInCallbackRequest({
Expand All @@ -136,12 +144,19 @@ describe('handleSignInCallbackRequest', () => {
origin: mockOrigin,
});

expect(response.status).toBe(400);
expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url);
expect(response.status).toBe(expectedStatus);
expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url);
expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(request, [
PKCE_COOKIE_NAME,
STATE_COOKIE_NAME,
]);

if (expectedStatus === 302) {
expect(mockGetRedirectOrDefault).toHaveBeenCalledWith(
mockHandlerInput.redirectOnSignOutComplete,
);
expect(response.headers.get('Location')).toBe(expectedRedirect);
}
},
);

Expand All @@ -151,9 +166,11 @@ describe('handleSignInCallbackRequest', () => {
const mockSignInCallbackUrl =
'https://example.com/api/auth/sign-in-callback';
const mockError = 'invalid_grant';
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
mockParseSignInCallbackUrl.mockReturnValueOnce({
code: mockCode,
state: 'not_important_for_this_test',
error: null,
errorDescription: null,
});
mockGetCookieValuesFromRequest.mockReturnValueOnce({
[STATE_COOKIE_NAME]: 'not_important_for_this_test',
Expand Down Expand Up @@ -245,9 +262,11 @@ describe('handleSignInCallbackRequest', () => {
mockCreateAuthFlowProofCookiesRemoveOptions.mockReturnValueOnce(
mockCreateAuthFlowProofCookiesRemoveOptionsResult,
);
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
mockParseSignInCallbackUrl.mockReturnValueOnce({
code: mockCode,
state: 'not_important_for_this_test',
error: null,
errorDescription: null,
});
mockGetCookieValuesFromRequest.mockReturnValueOnce({
[STATE_COOKIE_NAME]: 'not_important_for_this_test',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
exchangeAuthNTokens,
getCookieValuesFromNextApiRequest,
getRedirectOrDefault,
resolveCodeAndStateFromUrl,
parseSignInCallbackUrl,
resolveRedirectSignInUrl,
} from '../../../src/auth/utils';
import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types';
Expand All @@ -26,6 +26,11 @@ import {
} from '../../../src/auth/constant';
import { createMockNextApiResponse } from '../testUtils';

import {
ERROR_CLIENT_COOKIE_COMBINATIONS,
ERROR_URL_PARAMS_COMBINATIONS,
} from './signInCallbackErrorCombinations';

jest.mock('../../../src/auth/utils');

const mockAppendSetCookieHeadersToNextApiResponse = jest.mocked(
Expand All @@ -48,7 +53,7 @@ const mockExchangeAuthNTokens = jest.mocked(exchangeAuthNTokens);
const mockGetCookieValuesFromNextApiRequest = jest.mocked(
getCookieValuesFromNextApiRequest,
);
const mockResolveCodeAndStateFromUrl = jest.mocked(resolveCodeAndStateFromUrl);
const mockParseSignInCallbackUrl = jest.mocked(parseSignInCallbackUrl);
const mockResolveRedirectSignInUrl = jest.mocked(resolveRedirectSignInUrl);
const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault);

Expand All @@ -66,6 +71,7 @@ describe('handleSignInCallbackRequest', () => {
mockResponseEnd,
mockResponseStatus,
mockResponseSend,
mockResponseRedirect,
mockResponse,
} = createMockNextApiResponse();

Expand All @@ -84,25 +90,32 @@ describe('handleSignInCallbackRequest', () => {
mockCreateTokenCookiesSetOptions.mockClear();
mockExchangeAuthNTokens.mockClear();
mockGetCookieValuesFromNextApiRequest.mockClear();
mockResolveCodeAndStateFromUrl.mockClear();
mockParseSignInCallbackUrl.mockClear();
mockResolveRedirectSignInUrl.mockClear();
mockGetRedirectOrDefault.mockClear();

mockResponseAppendHeader.mockClear();
mockResponseEnd.mockClear();
mockResponseStatus.mockClear();
mockResponseSend.mockClear();
mockResponseRedirect.mockClear();
});

test.each([
[null, 'state'],
['state', null],
])(
'returns a 400 response when request.url contains query params: code=%s, state=%s',
async (code, state) => {
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
test.each(ERROR_URL_PARAMS_COMBINATIONS)(
'returns a $expectedStatus response when request.url contains query params: code=$code, state=$state, error=$error, error_description=$errorDescription',
async ({
code,
state,
error,
errorDescription,
expectedStatus,
expectedRedirect,
}) => {
mockParseSignInCallbackUrl.mockReturnValueOnce({
code,
state,
error,
errorDescription,
});
const url = '/api/auth/sign-in-callback';
const mockRequest = {
Expand All @@ -120,36 +133,40 @@ describe('handleSignInCallbackRequest', () => {
origin: mockOrigin,
});

expect(mockResponseStatus).toHaveBeenCalledWith(400);
expect(mockResponseEnd).toHaveBeenCalled();
expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url);
if (expectedStatus === 400) {
expect(mockResponseStatus).toHaveBeenCalledWith(expectedStatus);
expect(mockResponseEnd).toHaveBeenCalled();
} else {
expect(mockGetRedirectOrDefault).toHaveBeenCalledWith(
mockHandlerInput.redirectOnSignOutComplete,
);
expect(mockResponseRedirect).toHaveBeenCalledWith(
expectedStatus,
expectedRedirect,
);
}
expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url);
},
);

test.each([
['client state cookie is missing', undefined, 'state', 'pkce'],
[
'client cookie state a different value from the state query parameter',
'state_different',
'state',
'pkce',
],
['client pkce cookie is missing', 'state', 'state', undefined],
])(
`returns a 400 response when %s`,
async (_, clientState, state, clientPkce) => {
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
test.each(ERROR_CLIENT_COOKIE_COMBINATIONS)(
`returns a $expectedStatus response when client cookies are: state=$state, pkce=$pkce and expected state value is 'state_b'`,
async ({ state, pkce, expectedStatus, expectedRedirect }) => {
mockParseSignInCallbackUrl.mockReturnValueOnce({
code: 'not_important_for_this_test',
state,
state: 'not_important_for_this_test',
error: null,
errorDescription: null,
});
mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({
[STATE_COOKIE_NAME]: clientState,
[PKCE_COOKIE_NAME]: clientPkce,
[STATE_COOKIE_NAME]: state,
[PKCE_COOKIE_NAME]: pkce,
});
const expectedState = 'state_b';

const url = `/api/auth/sign-in-callback?state=${state}&code=not_important_for_this_test`;
const url = `/api/auth/sign-in-callback?state=${expectedState}&code=not_important_for_this_test`;
const mockRequest = {
query: { state },
query: { state: expectedState },
url,
} as unknown as NextApiRequest;

Expand All @@ -163,9 +180,19 @@ describe('handleSignInCallbackRequest', () => {
origin: mockOrigin,
});

expect(mockResponseStatus).toHaveBeenCalledWith(400);
expect(mockResponseEnd).toHaveBeenCalled();
expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url);
if (expectedStatus === 400) {
expect(mockResponseStatus).toHaveBeenCalledWith(expectedStatus);
expect(mockResponseEnd).toHaveBeenCalled();
} else {
expect(mockGetRedirectOrDefault).toHaveBeenCalledWith(
mockHandlerInput.redirectOnSignOutComplete,
);
expect(mockResponseRedirect).toHaveBeenCalledWith(
expectedStatus,
expectedRedirect,
);
}
expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url);
expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith(
mockRequest,
[PKCE_COOKIE_NAME, STATE_COOKIE_NAME],
Expand All @@ -183,9 +210,11 @@ describe('handleSignInCallbackRequest', () => {
query: {},
url: '/api/auth/sign-in-callback',
} as unknown as NextApiRequest;
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
mockParseSignInCallbackUrl.mockReturnValueOnce({
code: mockCode,
state: 'not_important_for_this_test',
error: null,
errorDescription: null,
});
mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({
[STATE_COOKIE_NAME]: 'not_important_for_this_test',
Expand Down Expand Up @@ -277,9 +306,11 @@ describe('handleSignInCallbackRequest', () => {
mockCreateAuthFlowProofCookiesRemoveOptions.mockReturnValueOnce(
mockCreateAuthFlowProofCookiesRemoveOptionsResult,
);
mockResolveCodeAndStateFromUrl.mockReturnValueOnce({
mockParseSignInCallbackUrl.mockReturnValueOnce({
code: mockCode,
state: 'not_important_for_this_test',
error: null,
errorDescription: null,
});
mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({
[STATE_COOKIE_NAME]: 'not_important_for_this_test',
Expand Down
Loading

0 comments on commit 91a0248

Please sign in to comment.