Skip to content

Commit 84342a1

Browse files
committed
feat(nextjs): support Next.js 15 async cookies() in getSessionFromCookies
Next.js 15 made cookies() async (returns a Promise). The helper duck-typed on .get, missed it on the Promise, and returned null — a silent 'not logged in'. It now resolves a thenable store and returns a Promise; a non-thenable store still returns synchronously, so existing Next.js <=14 callers that did not await keep working (non-breaking). Also documents a middleware.ts / Edge example (NextRequest.cookies) and the returnTo-in-redirectTo, non-HttpOnly, and chunked-cookie caveats.
1 parent 5f03a09 commit 84342a1

3 files changed

Lines changed: 175 additions & 35 deletions

File tree

README.md

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,25 +314,71 @@ createClient({ domain, clientId, storage: memoryStorage })
314314

315315
## Next.js / server-side
316316

317-
Use cookie storage on the client, then read the session from `next/headers` on
318-
the server:
317+
Use `storage: 'cookie'` on the client, then read the session on the server with
318+
`getSessionFromCookies`. It returns the full `Session` (`access_token`,
319+
`refresh_token`, `expires_at`, `user`) or `null`, and accepts the `cookies()`
320+
object from `next/headers`, a `NextRequest.cookies` object, or a plain
321+
`{ name: value }` map.
322+
323+
`getSessionFromCookies` is **async** — always `await` it. In Next.js 15+,
324+
`cookies()` is also async, so `await` that too:
319325

320326
```ts
321-
// app/page.tsx
327+
// app/page.tsx (Next.js 15+)
322328
import { cookies } from 'next/headers'
323329
import { getSessionFromCookies } from '@faable/auth-js'
324330

325331
export default async function Page() {
326-
const session = getSessionFromCookies(cookies(), { clientId: '<client_id>' })
332+
const session = await getSessionFromCookies(await cookies(), {
333+
clientId: '<client_id>'
334+
})
327335
if (!session) return <SignIn />
328336
return <Dashboard user={session.user} />
329337
}
330338
```
331339

340+
On Next.js 14 and earlier `cookies()` is synchronous — drop the inner `await`
341+
(`await getSessionFromCookies(cookies(), …)`).
342+
343+
### Gating routes in middleware (Edge)
344+
345+
To keep protected content from ever reaching the browser without a session, gate
346+
it in `middleware.ts`. Pass `req.cookies` (a `NextRequest.cookies` object)
347+
directly:
348+
349+
```ts
350+
// middleware.ts
351+
import { NextRequest, NextResponse } from 'next/server'
352+
import { getSessionFromCookies } from '@faable/auth-js'
353+
354+
export async function middleware(req: NextRequest) {
355+
const session = await getSessionFromCookies(req.cookies, {
356+
clientId: '<client_id>'
357+
})
358+
if (!session) return NextResponse.redirect(new URL('/login', req.url))
359+
return NextResponse.next()
360+
}
361+
362+
export const config = { matcher: ['/((?!login|_next|favicon.ico).*)'] }
363+
```
364+
332365
Pass the same `clientId` you used in `createClient`. If you also passed a custom
333366
`storageKey` to `createClient`, mirror it here as `{ clientId, storageKey }` so
334367
the helper looks at the same cookie.
335368
369+
> **Security note.** This library writes the session cookie from JavaScript, so
370+
> it **cannot** be `HttpOnly` — an XSS can read the `access_token`. Treat XSS
371+
> prevention (CSP, escaping) as a hard requirement. The cookie may also be
372+
> **chunked** across `faableauth-<clientId>.0`, `.1`, … when large;
373+
> `getSessionFromCookies` reassembles the chunks for you, but any code that
374+
> reads the cookie by hand (another backend, an edge worker) must rejoin them.
375+
376+
> **`returnTo` vs `redirectTo`.** Don't embed `returnTo` inside the `redirectTo`
377+
> query (e.g. `redirectTo: '/callback?returnTo=/x'`) — pass `returnTo` as its
378+
> own option (`signInWith…({ returnTo: '/x' })`). The SDK stores it locally next
379+
> to the PKCE verifier and round-trips it back to you; keep `redirectTo` a clean
380+
> URL with no query.
381+
336382
## Documentation
337383
338384
For the full guides, API reference, and dashboard setup walkthroughs visit

src/lib/nextjs.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,75 @@ import { Session } from './types'
1010
* adapter builds the storage key and reassembles chunked cookies, so no
1111
* extra wiring is required.
1212
*
13-
* @param cookiesStore Either the result of `cookies()` from `next/headers`,
14-
* or any plain `{ name: value }` map. Adapters with a `get(name)` method
15-
* are detected automatically.
13+
* @param cookiesStore The result of `cookies()` from `next/headers`
14+
* (Next.js 15+ returns a Promise — pass it directly or pre-awaited, both
15+
* work), a `NextRequest.cookies` object (for `middleware.ts` / Edge), or any
16+
* plain `{ name: value }` map. Adapters with a `get(name)` method are
17+
* detected automatically.
1618
* @param options `{ clientId, storageKey? }`. `storageKey` defaults to the
1719
* library's built-in prefix — only set it when you customized
1820
* `storageKey` in `createClient`.
19-
* @returns The decoded {@link Session} or `null` when the cookie is absent
20-
* or malformed.
21+
* @returns The decoded {@link Session} or `null` when the cookie is absent or
22+
* malformed. When `cookiesStore` is a Promise (Next.js 15 `cookies()`) the
23+
* result is a Promise that resolves to the same; otherwise it is returned
24+
* synchronously (Next.js ≤14). `await` works either way, so always `await`.
2125
* @example
2226
* ```ts
23-
* // app/page.tsx
27+
* // app/page.tsx (Next.js 15 — cookies() is async)
2428
* import { cookies } from 'next/headers'
2529
* import { getSessionFromCookies } from '@faable/auth-js'
2630
*
2731
* export default async function Page() {
28-
* const session = getSessionFromCookies(cookies(), {
32+
* const session = await getSessionFromCookies(await cookies(), {
2933
* clientId: '<client_id>'
3034
* })
3135
* if (!session) return <SignIn />
3236
* return <Dashboard user={session.user} />
3337
* }
3438
* ```
39+
* @example
40+
* ```ts
41+
* // middleware.ts — gate routes at the edge before any HTML is sent
42+
* import { NextRequest, NextResponse } from 'next/server'
43+
* import { getSessionFromCookies } from '@faable/auth-js'
44+
*
45+
* export async function middleware(req: NextRequest) {
46+
* const session = await getSessionFromCookies(req.cookies, {
47+
* clientId: '<client_id>'
48+
* })
49+
* if (!session) return NextResponse.redirect(new URL('/login', req.url))
50+
* return NextResponse.next()
51+
* }
52+
* ```
3553
* @see {@link https://faable.com/docs/auth/quickstart/nextjs | Next.js Quickstart}
3654
*/
37-
export const getSessionFromCookies = (
55+
export function getSessionFromCookies(
56+
cookiesStore: Promise<unknown>,
57+
options: { clientId: string; storageKey?: string }
58+
): Promise<Session | null>
59+
export function getSessionFromCookies(
60+
cookiesStore: unknown,
61+
options: { clientId: string; storageKey?: string }
62+
): Session | null
63+
export function getSessionFromCookies(
64+
cookiesStore: any,
65+
options: { clientId: string; storageKey?: string }
66+
): Session | null | Promise<Session | null> {
67+
// Next.js 15 made `cookies()` async: it returns a Promise, so callers may
68+
// pass it un-awaited. When given a thenable we resolve it and return a
69+
// Promise; otherwise we stay synchronous so existing Next.js ≤14 callers
70+
// (which relied on the sync return) keep working unchanged. `await` works
71+
// on both shapes, so the recommended `await getSessionFromCookies(...)` is
72+
// always correct.
73+
if (cookiesStore && typeof cookiesStore.then === 'function') {
74+
return (cookiesStore as Promise<unknown>).then(store =>
75+
parseSession(store, options)
76+
)
77+
}
78+
return parseSession(cookiesStore, options)
79+
}
80+
81+
const parseSession = (
3882
cookiesStore: any,
3983
options: { clientId: string; storageKey?: string }
4084
): Session | null => {

tests/unit/getSessionFromCookies.test.ts

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,49 +17,99 @@ const DEFAULT_KEY = `${STORAGE_KEY}-${CLIENT_ID}`
1717
const encoded = encodeURIComponent(JSON.stringify(validSession))
1818

1919
describe('getSessionFromCookies', () => {
20-
it('parses a session from a Next.js cookies() store using clientId', () => {
20+
it('parses a session from a Next.js cookies() store using clientId', async () => {
2121
const cookiesStore = {
2222
get(name: string) {
2323
return name === DEFAULT_KEY ? { name, value: encoded } : undefined
2424
}
2525
}
2626
expect(
27-
getSessionFromCookies(cookiesStore, { clientId: CLIENT_ID })
27+
await getSessionFromCookies(cookiesStore, { clientId: CLIENT_ID })
2828
).toEqual(validSession)
2929
})
3030

31-
it('parses a session from a plain object map using clientId', () => {
31+
it('parses a session from a plain object map using clientId', async () => {
3232
const cookiesStore = { [DEFAULT_KEY]: encoded }
3333
expect(
34-
getSessionFromCookies(cookiesStore, { clientId: CLIENT_ID })
34+
await getSessionFromCookies(cookiesStore, { clientId: CLIENT_ID })
3535
).toEqual(validSession)
3636
})
3737

38-
it('honours a custom storageKey override', () => {
38+
it('honours a custom storageKey override', async () => {
3939
const customKey = `mi-prefix-${CLIENT_ID}`
4040
const cookiesStore = { [customKey]: encoded }
4141
expect(
42-
getSessionFromCookies(cookiesStore, {
42+
await getSessionFromCookies(cookiesStore, {
4343
clientId: CLIENT_ID,
4444
storageKey: 'mi-prefix'
4545
})
4646
).toEqual(validSession)
4747
})
4848

49-
it('returns null when the cookie is absent', () => {
49+
it('returns null when the cookie is absent', async () => {
5050
expect(
51-
getSessionFromCookies({ get: () => undefined }, { clientId: CLIENT_ID })
51+
await getSessionFromCookies(
52+
{ get: () => undefined },
53+
{
54+
clientId: CLIENT_ID
55+
}
56+
)
5257
).toBeNull()
53-
expect(getSessionFromCookies({}, { clientId: CLIENT_ID })).toBeNull()
58+
expect(await getSessionFromCookies({}, { clientId: CLIENT_ID })).toBeNull()
5459
})
5560

56-
it('returns null when the cookie value is malformed JSON', () => {
61+
it('returns null when the cookie value is malformed JSON', async () => {
5762
const cookiesStore = { [DEFAULT_KEY]: encodeURIComponent('{not-json') }
5863
expect(
59-
getSessionFromCookies(cookiesStore, { clientId: CLIENT_ID })
64+
await getSessionFromCookies(cookiesStore, { clientId: CLIENT_ID })
6065
).toBeNull()
6166
})
6267

68+
// Next.js 15 made `cookies()` async — callers may pass the un-awaited
69+
// Promise. Before this was async-aware the duck-typing missed `.get` on the
70+
// Promise and returned null silently.
71+
describe('Next.js 15 async cookies()', () => {
72+
it('awaits a Promise-wrapped cookies() store', async () => {
73+
const cookiesStore = {
74+
get(name: string) {
75+
return name === DEFAULT_KEY ? { name, value: encoded } : undefined
76+
}
77+
}
78+
expect(
79+
await getSessionFromCookies(Promise.resolve(cookiesStore), {
80+
clientId: CLIENT_ID
81+
})
82+
).toEqual(validSession)
83+
})
84+
85+
it('awaits a Promise-wrapped plain object map', async () => {
86+
const store = { [DEFAULT_KEY]: encoded }
87+
expect(
88+
await getSessionFromCookies(Promise.resolve(store), {
89+
clientId: CLIENT_ID
90+
})
91+
).toEqual(validSession)
92+
})
93+
94+
it('returns null for a Promise resolving to no session', async () => {
95+
expect(
96+
await getSessionFromCookies(Promise.resolve({}), {
97+
clientId: CLIENT_ID
98+
})
99+
).toBeNull()
100+
})
101+
102+
// Backward compatibility: a non-thenable store must still return
103+
// synchronously so existing Next.js ≤14 callers that don't `await` keep
104+
// working (a Promise here would be truthy and silently "always logged in").
105+
it('stays synchronous for a non-Promise store', () => {
106+
const store = { [DEFAULT_KEY]: encoded }
107+
const result = getSessionFromCookies(store, { clientId: CLIENT_ID })
108+
expect(result).not.toBeInstanceOf(Promise)
109+
expect(result).toEqual(validSession)
110+
})
111+
})
112+
63113
describe('chunked cookies', () => {
64114
const split = (str: string, size: number): string[] => {
65115
const out: string[] = []
@@ -68,7 +118,7 @@ describe('getSessionFromCookies', () => {
68118
return out
69119
}
70120

71-
it('reassembles `key.0`, `key.1`, … from a Next.js cookies() store', () => {
121+
it('reassembles `key.0`, `key.1`, … from a Next.js cookies() store', async () => {
72122
const chunks = split(encoded, 50)
73123
const store = {
74124
get(name: string) {
@@ -78,30 +128,30 @@ describe('getSessionFromCookies', () => {
78128
return idx < chunks.length ? { name, value: chunks[idx] } : undefined
79129
}
80130
}
81-
expect(getSessionFromCookies(store, { clientId: CLIENT_ID })).toEqual(
82-
validSession
83-
)
131+
expect(
132+
await getSessionFromCookies(store, { clientId: CLIENT_ID })
133+
).toEqual(validSession)
84134
})
85135

86-
it('reassembles chunks from a plain object map', () => {
136+
it('reassembles chunks from a plain object map', async () => {
87137
const chunks = split(encoded, 50)
88138
const store: Record<string, string> = {}
89139
chunks.forEach((c, i) => {
90140
store[`${DEFAULT_KEY}.${i}`] = c
91141
})
92-
expect(getSessionFromCookies(store, { clientId: CLIENT_ID })).toEqual(
93-
validSession
94-
)
142+
expect(
143+
await getSessionFromCookies(store, { clientId: CLIENT_ID })
144+
).toEqual(validSession)
95145
})
96146

97-
it('prefers a single un-chunked cookie when both shapes are present', () => {
147+
it('prefers a single un-chunked cookie when both shapes are present', async () => {
98148
const store = {
99149
[DEFAULT_KEY]: encoded,
100150
[`${DEFAULT_KEY}.0`]: 'stale-garbage'
101151
}
102-
expect(getSessionFromCookies(store, { clientId: CLIENT_ID })).toEqual(
103-
validSession
104-
)
152+
expect(
153+
await getSessionFromCookies(store, { clientId: CLIENT_ID })
154+
).toEqual(validSession)
105155
})
106156
})
107157
})

0 commit comments

Comments
 (0)