Skip to content

Commit c79eeab

Browse files
authored
feat: Take cookie from session (#1037) RELEASE
## Related Issues Fixes descope/etc#9622 ## Description make `session` to work also outside middleware, or of middleware was chained
1 parent 87b98e2 commit c79eeab

File tree

9 files changed

+556
-725
lines changed

9 files changed

+556
-725
lines changed

packages/sdks/nextjs-sdk/CHANGELOG.md

+171-139
Large diffs are not rendered by default.

packages/sdks/nextjs-sdk/README.md

+14-1
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ This setup ensures that you can clearly define which routes in your application
200200

201201
use the `session()` helper to read session information in Server Components and Route handlers.
202202

203-
Note: `session()` requires the `authMiddleware` to be used for the Server Component or Route handler that uses it.
203+
Note: While using `authMiddleware` is still recommended for session management (because it validates the session only once), `session()` can function without it. If `authMiddleware` does not set a session, `session()` will attempt to retrieve the session token from cookies, then parse and validate it.
204204

205205
Server Component:
206206

@@ -234,6 +234,19 @@ export async function GET() {
234234
}
235235
```
236236

237+
##### Optional Parameters
238+
239+
If the middleware did not set a session, The `session()` function will attempt to retrieve the session token from cookies and validates it, this requires the project ID to be either set in the environment variables or passed as a parameter to the function.
240+
241+
```
242+
session({ projectId?: string, baseUrl?: string })
243+
```
244+
245+
- **projectId:** The Descope Project ID. If not provided, the function will fall back to `DESCOPE_PROJECT_ID` from the environment variables.
246+
- **baseUrl:** The Descope API base URL.
247+
248+
This allows developers to use `session()` even if the project ID is not set in the environment.
249+
237250
#### Access Descope SDK in server side
238251

239252
Use `createSdk` function to create Descope SDK in server side.

packages/sdks/nextjs-sdk/examples/app-router/app/my-applications-portal/page.tsx

+1-3
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ export default () => (
1313
}}
1414
>
1515
<h1>Applications Portal</h1>
16-
<ApplicationsPortal
17-
widgetId="applications-portal-widget"
18-
/>
16+
<ApplicationsPortal widgetId="applications-portal-widget" />
1917
</div>
2018
);

packages/sdks/nextjs-sdk/src/server/authMiddleware.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ const getSessionJwt = (req: NextRequest): string | undefined => {
4646

4747
const matchWildcardRoute = (route: string, path: string) => {
4848
let regexPattern = route.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
49-
49+
5050
// Convert wildcard (*) to match path segments only
5151
regexPattern = regexPattern.replace(/\*/g, '[^/]*');
5252
const regex = new RegExp(`^${regexPattern}$`);
53-
53+
5454
return regex.test(path);
55-
};
55+
};
5656

5757
const isPublicRoute = (req: NextRequest, options: MiddlewareOptions) => {
5858
// Ensure publicRoutes and privateRoutes are arrays, defaulting to empty arrays if not defined
@@ -69,12 +69,16 @@ const isPublicRoute = (req: NextRequest, options: MiddlewareOptions) => {
6969
'Both publicRoutes and privateRoutes are defined. Ignoring privateRoutes.'
7070
);
7171
}
72-
return isDefaultPublicRoute || publicRoutes.some((route) => matchWildcardRoute(route, pathname))
72+
return (
73+
isDefaultPublicRoute ||
74+
publicRoutes.some((route) => matchWildcardRoute(route, pathname))
75+
);
7376
}
7477

7578
if (privateRoutes.length > 0) {
7679
return (
77-
isDefaultPublicRoute || !privateRoutes.some((route) => matchWildcardRoute(route, pathname))
80+
isDefaultPublicRoute ||
81+
!privateRoutes.some((route) => matchWildcardRoute(route, pathname))
7882
);
7983
}
8084

packages/sdks/nextjs-sdk/src/server/sdk.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import descopeSdk from '@descope/node-sdk';
22
import { baseHeaders } from './constants';
33

44
type Sdk = ReturnType<typeof descopeSdk>;
5-
type CreateSdkParams = Omit<Parameters<typeof descopeSdk>[0], 'projectId'> & {
5+
type CreateServerSdkParams = Omit<
6+
Parameters<typeof descopeSdk>[0],
7+
'projectId'
8+
> & {
69
projectId?: string | undefined;
710
};
811

12+
type CreateSdkParams = Pick<CreateServerSdkParams, 'projectId' | 'baseUrl'>;
13+
914
let globalSdk: Sdk;
1015

11-
export const createSdk = (config?: CreateSdkParams): Sdk =>
16+
export const createSdk = (config?: CreateServerSdkParams): Sdk =>
1217
descopeSdk({
1318
...config,
1419
projectId: config?.projectId || process.env.DESCOPE_PROJECT_ID,
@@ -20,9 +25,7 @@ export const createSdk = (config?: CreateSdkParams): Sdk =>
2025
}
2126
});
2227

23-
export const getGlobalSdk = (
24-
config?: Pick<CreateSdkParams, 'projectId' | 'baseUrl'>
25-
): Sdk => {
28+
export const getGlobalSdk = (config?: CreateSdkParams): Sdk => {
2629
if (!globalSdk) {
2730
if (!config?.projectId && !process.env.DESCOPE_PROJECT_ID) {
2831
throw new Error('Descope project ID is required to create the SDK');
@@ -32,3 +35,5 @@ export const getGlobalSdk = (
3235

3336
return globalSdk;
3437
};
38+
39+
export type { CreateSdkParams };
+52-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { AuthenticationInfo } from '@descope/node-sdk';
1+
/* eslint-disable no-console */
2+
import descopeSdk, { AuthenticationInfo } from '@descope/node-sdk';
23
import { NextApiRequest } from 'next';
3-
import { headers } from 'next/headers';
4+
import { cookies, headers } from 'next/headers';
45
import { DESCOPE_SESSION_HEADER } from './constants';
6+
import { getGlobalSdk, CreateSdkParams } from './sdk';
57

68
const extractSession = (
79
descopeSession?: string
@@ -18,17 +20,57 @@ const extractSession = (
1820
return undefined;
1921
}
2022
};
23+
24+
const getSessionFromCookie = async (
25+
config?: CreateSdkParams
26+
): Promise<AuthenticationInfo | undefined> => {
27+
try {
28+
const sessionCookie = (await cookies()).get(
29+
descopeSdk.SessionTokenCookieName
30+
);
31+
if (!sessionCookie?.value) {
32+
console.debug('Session cookie not found');
33+
return undefined;
34+
}
35+
const sdk = getGlobalSdk(config);
36+
const res = await sdk.validateJwt(sessionCookie.value);
37+
return res;
38+
} catch (err) {
39+
console.debug('Error getting session from cookie', err);
40+
return undefined;
41+
}
42+
};
43+
44+
// tries to extract the session header,
45+
// if it doesn't exist, it will attempt to get the session from the cookie
46+
const extractOrGetSession = async (
47+
sessionHeader?: string,
48+
config?: CreateSdkParams
49+
): Promise<AuthenticationInfo | undefined> => {
50+
const session = extractSession(sessionHeader);
51+
if (session) {
52+
return session;
53+
}
54+
55+
return getSessionFromCookie(config);
56+
};
57+
2158
// returns the session token if it exists in the headers
22-
// This function require middleware
23-
export const session = async (): Promise<AuthenticationInfo | undefined> => {
59+
export const session = async (
60+
config?: CreateSdkParams
61+
): Promise<AuthenticationInfo | undefined> => {
62+
// first attempt to get the session from the headers
2463
const reqHeaders = await headers();
2564
const sessionHeader = reqHeaders.get(DESCOPE_SESSION_HEADER);
26-
return extractSession(sessionHeader);
65+
return extractOrGetSession(sessionHeader, config);
2766
};
2867

2968
// returns the session token if it exists in the request headers
30-
// This function require middleware
31-
export const getSession = (
32-
req: NextApiRequest
33-
): AuthenticationInfo | undefined =>
34-
extractSession(req.headers[DESCOPE_SESSION_HEADER.toLowerCase()] as string);
69+
export const getSession = async (
70+
req: NextApiRequest,
71+
config?: CreateSdkParams
72+
): Promise<AuthenticationInfo | undefined> =>
73+
extractOrGetSession(
74+
req.headers[DESCOPE_SESSION_HEADER.toLowerCase()] as string,
75+
config
76+
);

packages/sdks/nextjs-sdk/test/server/authMiddleware.test.ts

+27-27
Original file line numberDiff line numberDiff line change
@@ -176,64 +176,64 @@ describe('authMiddleware', () => {
176176

177177
it('redirects unauthenticated users for private routes matching wildcard patterns', async () => {
178178
mockValidateJwt.mockRejectedValue(new Error('Invalid JWT'));
179-
179+
180180
const middleware = authMiddleware({
181-
privateRoutes: ['/private/*']
181+
privateRoutes: ['/private/*']
182182
});
183-
183+
184184
// Mock request to a route matching the wildcard pattern
185185
const mockReq = createMockNextRequest({ pathname: '/private/dashboard' });
186-
186+
187187
const response = await middleware(mockReq);
188-
188+
189189
// Expect a redirect since the user is unauthenticated
190190
expect(NextResponse.redirect).toHaveBeenCalledWith(expect.anything());
191191
expect(response).toEqual({
192-
pathname: DEFAULT_PUBLIC_ROUTES.signIn
192+
pathname: DEFAULT_PUBLIC_ROUTES.signIn
193193
});
194-
});
195-
196-
it('allows authenticated users for private routes matching wildcard patterns', async () => {
194+
});
195+
196+
it('allows authenticated users for private routes matching wildcard patterns', async () => {
197197
const authInfo = {
198-
jwt: 'validJwt',
199-
token: { iss: 'project-1', sub: 'user-123' }
198+
jwt: 'validJwt',
199+
token: { iss: 'project-1', sub: 'user-123' }
200200
};
201201
mockValidateJwt.mockImplementation(() => authInfo);
202-
202+
203203
const middleware = authMiddleware({
204-
privateRoutes: ['/private/*']
204+
privateRoutes: ['/private/*']
205205
});
206-
206+
207207
const mockReq = createMockNextRequest({
208-
pathname: '/private/settings',
209-
headers: { Authorization: 'Bearer validJwt' }
208+
pathname: '/private/settings',
209+
headers: { Authorization: 'Bearer validJwt' }
210210
});
211-
211+
212212
await middleware(mockReq);
213-
213+
214214
// Expect no redirect and that the session header is set
215215
expect(NextResponse.redirect).not.toHaveBeenCalled();
216216
expect(NextResponse.next).toHaveBeenCalled();
217-
217+
218218
const headersArg = (NextResponse.next as any as jest.Mock).mock.lastCall[0]
219-
.request.headers;
219+
.request.headers;
220220
expect(headersArg.get('x-descope-session')).toEqual(
221-
Buffer.from(JSON.stringify(authInfo)).toString('base64')
221+
Buffer.from(JSON.stringify(authInfo)).toString('base64')
222222
);
223-
});
224-
223+
});
224+
225225
it('allows unauthenticated users for public routes matching wildcard patterns', async () => {
226226
mockValidateJwt.mockRejectedValue(new Error('Invalid JWT'));
227-
227+
228228
const middleware = authMiddleware({
229229
publicRoutes: ['/public/*']
230230
});
231-
231+
232232
// Mock request to a route matching the wildcard pattern
233233
const mockReq = createMockNextRequest({ pathname: '/public/info' });
234-
234+
235235
await middleware(mockReq);
236-
236+
237237
// Expect no redirect since it's a public route
238238
expect(NextResponse.redirect).not.toHaveBeenCalled();
239239
expect(NextResponse.next).toHaveBeenCalled();

0 commit comments

Comments
 (0)