Skip to content

Commit 825d338

Browse files
authored
feat: support server-side auth flows with Cognito managed login (#14168)
* feat(adapter-nextjs): add runtimeOptions.cookies to createServerRunner (#13788) * feat(aws-amplify|adapter-nextjs): add runtimeOptions.cookies to createServerRunner * chore: resolve comments * chore(adapter-nextjs): adapt the latest impl. changes * feat(adapter-nextjs): add createAuthRouteHandlers to createServerRunner (#13801) * feat(aws-amplify|adapter-nextjs): add runtimeOptions.cookies to createServerRunner * feat(adapter-nextjs): add createAuthRouteHandlers to createServerRunner * chore(adapter-nextjs): resolve comments * chore(adapter-nextjs): remove unnecessary check * feat(adapter-nextjs): server-side auth flows integrating cognito hosted UI (#13827) * chore(auth): export necessary utilities and types to support server-side auth * chore(aws-amplify): export necessary utilities to support server-side auth * feat(adapter-nextjs): server-side auth api route integrating cognito hosted ui * chore(adapter-nextjs): resolve comments * refactor(adapter-nextjs): remove redundant username fallback * feat(adapter-nextjs): add user has signed in check before initiating sign-in and sign-up (#13839) * feat(adapter-nextjs): add user has signed in check before initiating sign-in and sign-up * chore(adapter-nextjs): rename hasUserSignedIn to hasActiveUserSession * fix(adapter-nextjs): make createAuthRouteHandlers interface work in both App and Pages routers (#13840) * feat(adapter-nextjs): set cookie secure: false with non-SSL domain (#13841) * feat(adapter-nextjs): allow cookie secure: false with non-SSL domain * fix(adapter-nextjs): wrong naming and impl. of isSSLOrigin * chore(adapter-nextjs): resolve comment * refactor(adapter-nextjs): use maxAge attribute to set cookie from server to avoid clock drift (#14103) * fix(adapter-nextjs): wrong use of nullish coalescing (#14112) * refactor(adapter-nextjs): remove redundant clockDrift cookie (#14114) refactor(adapter-nextjs): remove redundant clockDrift cookie ⤵️ Reasons: 1. token exachange is happening on a server - and production server rarely has wrong system time 2. when setting token cookies from server, it uses Max-Age header which is relative to the client system time. Clock drift became irrelevant 3. surely we can argue sever system time can go wrong too, however, a Next.js app API route can be executed on different servers (load balancing), there is no source of truth to generate a clock drift value * chore: enable tag publishing for server-auth (#14115) * fix(adapter-nextjs): wrong spot for checking app origin and auth config (#14119) * fix(adapter-nextjs): not await params async API in Next.js 15 (#14125) * feat(adapter-nextjs): surface redirect error and sign-in timeout error (#14116) * feat(adapter-nextjs): surface redirect error and sign-in timeout error * feat(adapter-nextjs): expose both error and errorDescription * chore(adapter-nextjs): remove unnecessary undefined fallback * chore(adapter-nextjs): add warning re: using http in production (#14134) * fix(core): generateRandomString uses Math.random() (#14132) * fix(core): generateRandomString uses Math.random() * chore(core): use better test to test actual logic * chore(aws-amplify/adapter-nextjs): remove extraneous deps (#14141) * fix(adapter-nextjs): removing only tokens and LastAuthUser cookies (#14152) * fix(adapter-nextjs): wrong cookie attributes get set sometimes (#14169) * chore: add E2E tests for next.js server auth * chore: disable tag release * fix(aws-amplify|api): internals export paths
1 parent 289f3e8 commit 825d338

File tree

117 files changed

+7332
-162
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

117 files changed

+7332
-162
lines changed

.github/integ-config/integ-all.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ tests:
878878
sample_name: next-use-cases-14
879879
spec: next-use-cases
880880
yarn_script: ci:next-use-cases-test
881-
yarn_script_args: 14
881+
yarn_script_args: '14'
882882
browser: [chrome]
883883
- test_name: integ_next-use-cases-15
884884
desc: 'Next.js use cases tests with v15'
@@ -887,7 +887,25 @@ tests:
887887
sample_name: next-use-cases-15
888888
spec: next-use-cases
889889
yarn_script: ci:next-use-cases-test
890-
yarn_script_args: 15
890+
yarn_script_args: '15'
891+
browser: [chrome]
892+
- test_name: integ_next-use-cases-server-auth-14
893+
desc: 'Next.js server-side auth use cases tests with v14'
894+
framework: next
895+
category: ssr-adapter
896+
sample_name: next-use-cases-server-auth-14
897+
spec: next-use-cases-server-auth
898+
yarn_script: ci:next-use-cases-test
899+
yarn_script_args: server-auth-14
900+
browser: [chrome]
901+
- test_name: integ_next-use-cases-server-auth-15
902+
desc: 'Next.js server-side auth use cases tests with v15'
903+
framework: next
904+
category: ssr-adapter
905+
sample_name: next-use-cases-server-auth-15
906+
spec: next-use-cases-server-auth
907+
yarn_script: ci:next-use-cases-test
908+
yarn_script_args: server-auth-15
891909
browser: [chrome]
892910
- test_name: integ_next_mfa_req_email
893911
desc: 'mfa required with email sign in attribute'

.github/workflows/callable-e2e-test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ on:
3939
type: string
4040
yarn_script_args:
4141
required: false
42-
type: number
42+
type: string
4343
env:
4444
required: false
4545
type: string
@@ -141,7 +141,7 @@ jobs:
141141
$E2E_YARN_SCRIPT \
142142
-n $E2E_RETRY_COUNT
143143
else
144-
yarn "$E2E_YARN_SCRIPT" "$E2E_YARN_SCRIPT_ARGS"
144+
yarn "$E2E_YARN_SCRIPT" "$E2E_YARN_SCRIPT_ARGS" "$E2E_SPEC"
145145
fi
146146
- name: Upload artifact
147147
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0

.github/workflows/callable-e2e-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
timeout_minutes: ${{ matrix.integ-config.timeout_minutes || 35 }}
4545
retry_count: ${{ matrix.integ-config.retry_count || 3 }}
4646
yarn_script: ${{ matrix.integ-config.yarn_script || '' }}
47-
yarn_script_args: ${{ matrix.integ-config.yarn_script_args || 15 }}
47+
yarn_script_args: ${{ matrix.integ-config.yarn_script_args || '15' }}
4848
env: ${{ matrix.integ-config.env && toJSON(matrix.integ-config.env) || '{}' }}
4949

5050
# e2e-test-runner-headless:

packages/adapter-nextjs/__tests__/api/generateServerClient.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ResourcesConfig } from '@aws-amplify/core';
2-
import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils';
1+
import { ResourcesConfig } from 'aws-amplify';
2+
import { parseAmplifyConfig } from 'aws-amplify/utils';
33

44
import {
55
generateServerClientUsingCookies,
@@ -34,8 +34,8 @@ jest.mock('../../src/utils', () => ({
3434
createRunWithAmplifyServerContext: jest.fn(() => jest.fn()),
3535
createCookieStorageAdapterFromNextServerContext: jest.fn(),
3636
}));
37-
jest.mock('@aws-amplify/core/internals/utils', () => ({
38-
...jest.requireActual('@aws-amplify/core/internals/utils'),
37+
jest.mock('aws-amplify/utils', () => ({
38+
...jest.requireActual('aws-amplify/utils'),
3939
parseAmplifyConfig: jest.fn(() => mockAmplifyConfig),
4040
}));
4141

@@ -95,7 +95,7 @@ describe('generateServerClient', () => {
9595
graphql: mockGraphql,
9696
}));
9797

98-
jest.mock('@aws-amplify/core/internals/adapter-core', () => ({
98+
jest.mock('aws-amplify/adapter-core/internals', () => ({
9999
getAmplifyServerContext: jest.fn(),
100100
}));
101101

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { ResourcesConfig } from 'aws-amplify';
2+
import {
3+
assertOAuthConfig,
4+
assertTokenProviderConfig,
5+
} from 'aws-amplify/adapter-core/internals';
6+
7+
import { createAuthRouteHandlersFactory } from '../../src/auth/createAuthRouteHandlersFactory';
8+
import { handleAuthApiRouteRequestForAppRouter } from '../../src/auth/handleAuthApiRouteRequestForAppRouter';
9+
import { handleAuthApiRouteRequestForPagesRouter } from '../../src/auth/handleAuthApiRouteRequestForPagesRouter';
10+
import { NextServer } from '../../src';
11+
import {
12+
AuthRouteHandlers,
13+
CreateAuthRouteHandlersFactoryInput,
14+
CreateAuthRoutesHandlersInput,
15+
} from '../../src/auth/types';
16+
import {
17+
isAuthRoutesHandlersContext,
18+
isNextApiRequest,
19+
isNextApiResponse,
20+
isNextRequest,
21+
isValidOrigin,
22+
} from '../../src/auth/utils';
23+
import { globalSettings } from '../../src/utils';
24+
25+
jest.mock('aws-amplify/adapter-core/internals', () => ({
26+
...jest.requireActual('aws-amplify/adapter-core/internals'),
27+
assertOAuthConfig: jest.fn(),
28+
assertTokenProviderConfig: jest.fn(),
29+
}));
30+
jest.mock('../../src/auth/handleAuthApiRouteRequestForAppRouter');
31+
jest.mock('../../src/auth/handleAuthApiRouteRequestForPagesRouter');
32+
jest.mock('../../src/auth/utils');
33+
jest.mock('../../src/utils', () => ({
34+
globalSettings: {
35+
isServerSideAuthEnabled: jest.fn(() => true),
36+
enableServerSideAuth: jest.fn(),
37+
setRuntimeOptions: jest.fn(),
38+
getRuntimeOptions: jest.fn(() => ({
39+
cookies: {
40+
sameSite: 'strict',
41+
},
42+
})),
43+
isSSLOrigin: jest.fn(() => true),
44+
setIsSSLOrigin: jest.fn(),
45+
},
46+
}));
47+
48+
const mockAmplifyConfig: ResourcesConfig = {
49+
Auth: {
50+
Cognito: {
51+
identityPoolId: '123',
52+
userPoolId: 'abc',
53+
userPoolClientId: 'def',
54+
loginWith: {
55+
oauth: {
56+
domain: 'example.com',
57+
responseType: 'code',
58+
redirectSignIn: ['https://example.com/signin'],
59+
redirectSignOut: ['https://example.com/signout'],
60+
scopes: ['openid', 'email'],
61+
},
62+
},
63+
},
64+
},
65+
};
66+
67+
const mockAssertTokenProviderConfig = jest.mocked(assertTokenProviderConfig);
68+
const mockAssertOAuthConfig = jest.mocked(assertOAuthConfig);
69+
const mockHandleAuthApiRouteRequestForAppRouter = jest.mocked(
70+
handleAuthApiRouteRequestForAppRouter,
71+
);
72+
const mockHandleAuthApiRouteRequestForPagesRouter = jest.mocked(
73+
handleAuthApiRouteRequestForPagesRouter,
74+
);
75+
const mockIsNextApiRequest = jest.mocked(isNextApiRequest);
76+
const mockIsNextApiResponse = jest.mocked(isNextApiResponse);
77+
const mockIsNextRequest = jest.mocked(isNextRequest);
78+
const mockIsAuthRoutesHandlersContext = jest.mocked(
79+
isAuthRoutesHandlersContext,
80+
);
81+
const mockIsValidOrigin = jest.mocked(isValidOrigin);
82+
const mockRunWithAmplifyServerContext =
83+
jest.fn() as jest.MockedFunction<NextServer.RunOperationWithContext>;
84+
85+
describe('createAuthRoutesHandlersFactory', () => {
86+
const AMPLIFY_APP_ORIGIN = 'https://example.com';
87+
88+
beforeAll(() => {
89+
mockIsValidOrigin.mockReturnValue(true);
90+
});
91+
92+
describe('the created createAuthRouteHandlers function', () => {
93+
it('throws an error if the AMPLIFY_APP_ORIGIN environment variable is not defined', () => {
94+
const throwingFunc = createAuthRouteHandlersFactory({
95+
config: mockAmplifyConfig,
96+
amplifyAppOrigin: undefined,
97+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
98+
globalSettings,
99+
});
100+
expect(() => throwingFunc()).toThrow(
101+
'Could not find the AMPLIFY_APP_ORIGIN environment variable.',
102+
);
103+
});
104+
105+
it('throws an error if the AMPLIFY_APP_ORIGIN environment variable is invalid', () => {
106+
mockIsValidOrigin.mockReturnValueOnce(false);
107+
const throwingFunc = createAuthRouteHandlersFactory({
108+
config: mockAmplifyConfig,
109+
amplifyAppOrigin: 'domain-without-protocol.com',
110+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
111+
globalSettings,
112+
});
113+
expect(() => throwingFunc()).toThrow(
114+
'AMPLIFY_APP_ORIGIN environment variable contains an invalid origin string.',
115+
);
116+
});
117+
118+
it('calls config assertion functions to validate the Auth configuration', () => {
119+
const func = createAuthRouteHandlersFactory({
120+
config: mockAmplifyConfig,
121+
amplifyAppOrigin: AMPLIFY_APP_ORIGIN,
122+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
123+
globalSettings,
124+
});
125+
126+
func();
127+
128+
expect(mockAssertTokenProviderConfig).toHaveBeenCalledWith(
129+
mockAmplifyConfig.Auth?.Cognito,
130+
);
131+
expect(mockAssertOAuthConfig).toHaveBeenCalledWith(
132+
mockAmplifyConfig.Auth!.Cognito,
133+
);
134+
});
135+
});
136+
137+
describe('the created route handler function', () => {
138+
const testCreateAuthRoutesHandlersFactoryInput: CreateAuthRouteHandlersFactoryInput =
139+
{
140+
config: mockAmplifyConfig,
141+
amplifyAppOrigin: AMPLIFY_APP_ORIGIN,
142+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
143+
globalSettings,
144+
};
145+
const testCreateAuthRoutesHandlersInput: CreateAuthRoutesHandlersInput = {
146+
customState: 'random-state',
147+
redirectOnSignInComplete: '/home',
148+
redirectOnSignOutComplete: '/login',
149+
};
150+
let handler: AuthRouteHandlers;
151+
152+
beforeAll(() => {
153+
const createAuthRoutesHandlers = createAuthRouteHandlersFactory(
154+
testCreateAuthRoutesHandlersFactoryInput,
155+
);
156+
handler = createAuthRoutesHandlers(testCreateAuthRoutesHandlersInput);
157+
});
158+
159+
afterEach(() => {
160+
mockIsAuthRoutesHandlersContext.mockReset();
161+
mockIsNextApiRequest.mockReset();
162+
mockIsNextApiResponse.mockReset();
163+
mockIsNextRequest.mockReset();
164+
});
165+
166+
it('calls handleAuthApiRouteRequestForPagesRouter when 1st param is a NextApiRequest and 2nd param is a NextApiResponse', async () => {
167+
const param1 = {} as any;
168+
const param2 = {} as any;
169+
mockIsNextApiRequest.mockReturnValueOnce(true);
170+
mockIsNextApiResponse.mockReturnValueOnce(true);
171+
mockIsNextRequest.mockReturnValueOnce(false);
172+
mockIsAuthRoutesHandlersContext.mockReturnValueOnce(false);
173+
174+
await handler(param1, param2);
175+
176+
expect(mockHandleAuthApiRouteRequestForPagesRouter).toHaveBeenCalledWith({
177+
request: param1,
178+
response: param2,
179+
handlerInput: testCreateAuthRoutesHandlersInput,
180+
oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth,
181+
setCookieOptions: {
182+
sameSite: 'strict',
183+
},
184+
origin: 'https://example.com',
185+
userPoolClientId: 'def',
186+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
187+
});
188+
});
189+
190+
it('calls handleAuthApiRouteRequestForAppRouter when 1st param is a NextRequest and the 2nd param is a AuthRoutesHandlersContext', async () => {
191+
const request = {} as any;
192+
const context = {} as any;
193+
mockIsNextApiRequest.mockReturnValueOnce(false);
194+
mockIsNextApiResponse.mockReturnValueOnce(false);
195+
mockIsNextRequest.mockReturnValueOnce(true);
196+
mockIsAuthRoutesHandlersContext.mockReturnValueOnce(true);
197+
198+
await handler(request, context);
199+
200+
expect(mockHandleAuthApiRouteRequestForAppRouter).toHaveBeenCalledWith({
201+
request,
202+
handlerContext: context,
203+
handlerInput: testCreateAuthRoutesHandlersInput,
204+
oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth,
205+
setCookieOptions: {
206+
sameSite: 'strict',
207+
},
208+
origin: 'https://example.com',
209+
userPoolClientId: 'def',
210+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
211+
});
212+
});
213+
214+
it('throws an error when the request and context/response combination is invalid', () => {
215+
const request = {} as any;
216+
const context = {} as any;
217+
mockIsNextApiRequest.mockReturnValueOnce(false);
218+
mockIsNextApiResponse.mockReturnValueOnce(false);
219+
mockIsNextRequest.mockReturnValueOnce(false);
220+
mockIsAuthRoutesHandlersContext.mockReturnValueOnce(false);
221+
222+
expect(handler(request, context)).rejects.toThrow(
223+
'Invalid request and context/response combination. The request cannot be handled.',
224+
);
225+
});
226+
227+
it('uses default values for parameters that have values as undefined', async () => {
228+
(globalSettings.getRuntimeOptions as jest.Mock).mockReturnValueOnce({});
229+
const createAuthRoutesHandlers = createAuthRouteHandlersFactory({
230+
config: mockAmplifyConfig,
231+
amplifyAppOrigin: AMPLIFY_APP_ORIGIN,
232+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
233+
globalSettings,
234+
});
235+
const handlerWithDefaultParamValues =
236+
createAuthRoutesHandlers(/* undefined */);
237+
238+
const request = {} as any;
239+
const response = {} as any;
240+
241+
mockIsNextApiRequest.mockReturnValueOnce(true);
242+
mockIsNextApiResponse.mockReturnValueOnce(true);
243+
mockIsNextRequest.mockReturnValueOnce(false);
244+
mockIsAuthRoutesHandlersContext.mockReturnValueOnce(false);
245+
246+
await handlerWithDefaultParamValues(request, response);
247+
248+
expect(handleAuthApiRouteRequestForPagesRouter).toHaveBeenCalledWith({
249+
request,
250+
response,
251+
handlerInput: {},
252+
oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth,
253+
setCookieOptions: {},
254+
origin: 'https://example.com',
255+
userPoolClientId: 'def',
256+
runWithAmplifyServerContext: mockRunWithAmplifyServerContext,
257+
});
258+
});
259+
});
260+
});

0 commit comments

Comments
 (0)