Skip to content

Commit 043b252

Browse files
refactor: decouple CSRF-state (#3142)
* refactor: decouple csrf token from state * refactor: simplify pkce-handler
1 parent e9ac11b commit 043b252

File tree

8 files changed

+145
-108
lines changed

8 files changed

+145
-108
lines changed

src/core/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@ export async function NextAuthHandler<
6161
req.cookies?.[options.cookies.sessionToken.name] ||
6262
req.headers?.Authorization?.replace("Bearer ", "")
6363

64-
const codeVerifier = req.cookies?.[options.cookies.pkceCodeVerifier.name]
65-
6664
if (req.method === "GET") {
6765
const render = renderPage({ options, query: req.query, cookies })
6866
const { pages } = options
@@ -98,9 +96,9 @@ export async function NextAuthHandler<
9896
query: req.query,
9997
method: req.method,
10098
headers: req.headers,
99+
cookies: req.cookies,
101100
options,
102101
sessionToken,
103-
codeVerifier,
104102
})
105103
if (callback.cookies) cookies.push(...callback.cookies)
106104
return { ...callback, cookies }
@@ -180,9 +178,9 @@ export async function NextAuthHandler<
180178
query: req.query,
181179
method: req.method,
182180
headers: req.headers,
181+
cookies: req.cookies,
183182
options,
184183
sessionToken,
185-
codeVerifier,
186184
})
187185
if (callback.cookies) cookies.push(...callback.cookies)
188186
return { ...callback, cookies }

src/core/lib/cookie.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
205205
secure: useSecureCookies,
206206
},
207207
},
208+
state: {
209+
name: `${cookiePrefix}next-auth.state`,
210+
options: {
211+
httpOnly: true,
212+
sameSite: "lax",
213+
path: "/",
214+
secure: useSecureCookies,
215+
},
216+
},
208217
}
209218
}
210219

src/core/lib/oauth/authorization-url.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { openidClient } from "./client"
22
import { oAuth1Client } from "./client-legacy"
33
import { createState } from "./state-handler"
44
import { createPKCE } from "./pkce-handler"
5-
import { InternalOptions } from "../../../lib/types"
6-
import { IncomingRequest } from "../.."
7-
import { Cookie } from "../cookie"
5+
6+
import type { AuthorizationParameters } from "openid-client"
7+
import type { InternalOptions } from "../../../lib/types"
8+
import type { IncomingRequest } from "../.."
9+
import type { Cookie } from "../cookie"
810

911
/**
1012
*
@@ -48,24 +50,28 @@ export default async function getAuthorizationUrl(params: {
4850
return { redirect: url }
4951
}
5052

51-
const cookies: Cookie[] = []
5253
const client = await openidClient(options)
54+
55+
const authorizationParams: AuthorizationParameters = params
56+
const cookies: Cookie[] = []
57+
58+
const state = await createState(options)
59+
if (state) {
60+
authorizationParams.state = state.value
61+
cookies.push(state.cookie)
62+
}
63+
5364
const pkce = await createPKCE(options)
54-
if (pkce?.cookie) {
65+
if (pkce) {
66+
authorizationParams.code_challenge = pkce.code_challenge
67+
authorizationParams.code_challenge_method = pkce.code_challenge_method
5568
cookies.push(pkce.cookie)
5669
}
5770

58-
const url = client.authorizationUrl({
59-
...params,
60-
...pkce,
61-
state: createState(options),
62-
})
71+
const url = client.authorizationUrl(authorizationParams)
6372

64-
logger.debug("GET_AUTHORIZATION_URL", { url })
65-
return {
66-
redirect: url,
67-
cookies,
68-
}
73+
logger.debug("GET_AUTHORIZATION_URL", { url, cookies })
74+
return { redirect: url, cookies }
6975
} catch (error) {
7076
logger.error("GET_AUTHORIZATION_URL_ERROR", error as Error)
7177
throw error

src/core/lib/oauth/callback.ts

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1-
import { TokenSet } from "openid-client"
1+
import { CallbackParamsType, TokenSet } from "openid-client"
22
import { openidClient } from "./client"
33
import { oAuth1Client } from "./client-legacy"
4-
import { getState } from "./state-handler"
4+
import { useState } from "./state-handler"
55
import { usePKCECodeVerifier } from "./pkce-handler"
66
import { OAuthCallbackError } from "../../errors"
7-
import { Account, LoggerInstance, Profile } from "../../.."
8-
import { OAuthChecks, OAuthConfig } from "../../../providers"
9-
import { InternalOptions } from "../../../lib/types"
10-
import { IncomingRequest, OutgoingResponse } from "../.."
7+
8+
import type { Account, LoggerInstance, Profile } from "../../.."
9+
import type { OAuthChecks, OAuthConfig } from "../../../providers"
10+
import type { InternalOptions } from "../../../lib/types"
11+
import type { IncomingRequest, OutgoingResponse } from "../.."
12+
import type { Cookie } from "../cookie"
1113

1214
export default async function oAuthCallback(params: {
1315
options: InternalOptions<"oauth">
1416
query: IncomingRequest["query"]
1517
body: IncomingRequest["body"]
1618
method: IncomingRequest["method"]
17-
codeVerifier?: string
19+
cookies: IncomingRequest["cookies"]
1820
}): Promise<GetProfileResult & { cookies?: OutgoingResponse["cookies"] }> {
19-
const { options, query, body, method, codeVerifier } = params
21+
const { options, query, body, method, cookies } = params
2022
const { logger, provider } = options
2123

2224
const errorMessage = body?.error ?? query?.error
@@ -66,15 +68,24 @@ export default async function oAuthCallback(params: {
6668

6769
let tokens: TokenSet
6870

69-
const pkce = await usePKCECodeVerifier({
70-
options,
71-
codeVerifier,
72-
})
73-
const checks: OAuthChecks = {
74-
code_verifier: pkce?.codeVerifier,
75-
state: getState(options),
71+
const checks: OAuthChecks = {}
72+
const resCookies: Cookie[] = []
73+
74+
const state = await useState(cookies?.[options.cookies.state.name], options)
75+
76+
if (state) {
77+
checks.state = state.value
78+
resCookies.push(state.cookie)
79+
}
80+
81+
const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name]
82+
const pkce = await usePKCECodeVerifier(codeVerifier, options)
83+
if (pkce) {
84+
checks.code_verifier = pkce.codeVerifier
85+
resCookies.push(pkce.cookie)
7686
}
77-
const params = {
87+
88+
const params: CallbackParamsType = {
7889
...client.callbackParams({
7990
url: `http://n?${new URLSearchParams(query)}`,
8091
// TODO: Ask to allow object to be passed upstream:
@@ -136,12 +147,12 @@ export default async function oAuthCallback(params: {
136147
tokens,
137148
logger,
138149
})
139-
return {
140-
...profileResult,
141-
cookies: pkce?.cookie ? [pkce.cookie] : undefined,
142-
}
150+
return { ...profileResult, cookies: resCookies }
143151
} catch (error) {
144-
logger.error("OAUTH_CALLBACK_ERROR", { error: error as Error, providerId: provider.id })
152+
logger.error("OAUTH_CALLBACK_ERROR", {
153+
error: error as Error,
154+
providerId: provider.id,
155+
})
145156
throw new OAuthCallbackError(error as Error)
146157
}
147158
}

src/core/lib/oauth/pkce-handler.ts

Lines changed: 29 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -34,79 +34,62 @@ export async function createPKCE(options: InternalOptions<"oauth">): PKCE {
3434
// Provider does not support PKCE, return nothing.
3535
return
3636
}
37-
const codeVerifier = generators.codeVerifier()
38-
const codeChallenge = generators.codeChallenge(codeVerifier)
37+
const code_verifier = generators.codeVerifier()
38+
const code_challenge = generators.codeChallenge(code_verifier)
39+
40+
const expires = new Date()
41+
expires.setTime(expires.getTime() + PKCE_MAX_AGE * 1000)
3942

4043
// Encrypt code_verifier and save it to an encrypted cookie
41-
const encryptedCodeVerifier = await jwt.encode({
42-
// @ts-expect-error
43-
maxAge: PKCE_MAX_AGE,
44+
const encodedVerifier = await jwt.encode({
4445
...options.jwt,
45-
token: { code_verifier: codeVerifier },
46+
maxAge: PKCE_MAX_AGE,
47+
token: { code_verifier },
4648
})
4749

48-
const cookieExpires = new Date()
49-
cookieExpires.setTime(cookieExpires.getTime() + PKCE_MAX_AGE * 1000)
50-
5150
logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", {
52-
pkce: {
53-
code_challenge: codeChallenge,
54-
code_verifier: codeVerifier,
55-
},
56-
method: PKCE_CODE_CHALLENGE_METHOD,
51+
code_challenge,
52+
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
53+
code_verifier,
54+
PKCE_MAX_AGE,
5755
})
56+
5857
return {
58+
code_challenge,
59+
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
5960
cookie: {
6061
name: cookies.pkceCodeVerifier.name,
61-
value: encryptedCodeVerifier,
62-
options: {
63-
expires: cookieExpires.toISOString(),
64-
...cookies.pkceCodeVerifier.options,
65-
},
62+
value: encodedVerifier,
63+
options: { ...cookies.pkceCodeVerifier.options, expires },
6664
},
67-
code_challenge: codeChallenge,
68-
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
6965
}
7066
}
7167

7268
/**
7369
* Returns code_verifier if provider uses PKCE,
74-
* and clears the cookie afterwards.
70+
* and clears the container cookie afterwards.
7571
*/
76-
export async function usePKCECodeVerifier(params: {
72+
export async function usePKCECodeVerifier(
73+
codeVerifier: string | undefined,
7774
options: InternalOptions<"oauth">
78-
codeVerifier?: string
79-
}): Promise<
80-
| {
81-
codeVerifier?: string
82-
cookie?: Cookie
83-
}
84-
| undefined
85-
> {
86-
const { options, codeVerifier } = params
75+
): Promise<{ codeVerifier: string; cookie: Cookie } | undefined> {
8776
const { cookies, provider } = options
8877

8978
if (!provider?.checks?.includes("pkce") || !codeVerifier) {
9079
return
9180
}
9281

93-
const pkce = await jwt.decode({
82+
const pkce = (await jwt.decode({
9483
...options.jwt,
9584
token: codeVerifier,
96-
})
97-
98-
// remove PKCE cookie after it has been used up
99-
const cookie: Cookie = {
100-
name: cookies.pkceCodeVerifier.name,
101-
value: "",
102-
options: {
103-
...cookies.pkceCodeVerifier.options,
104-
maxAge: 0,
105-
},
106-
}
85+
})) as any
10786

10887
return {
109-
codeVerifier: (pkce?.code_verifier as any) ?? undefined,
110-
cookie,
88+
codeVerifier: pkce?.code_verifier ?? undefined,
89+
cookie: {
90+
name: cookies.pkceCodeVerifier.name,
91+
value: "",
92+
options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 },
93+
},
11194
}
11295
}

src/core/lib/oauth/state-handler.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,63 @@
1-
import { createHash } from "crypto"
2-
import { InternalOptions } from "src/lib/types"
1+
import { generators } from "openid-client"
32

4-
/** Returns state if provider supports it */
5-
export function createState(options: InternalOptions<"oauth">) {
6-
const { csrfToken, logger, provider } = options
3+
import type { InternalOptions } from "src/lib/types"
4+
import type { Cookie } from "../cookie"
5+
6+
const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds
7+
8+
/** Returns state if the provider supports it */
9+
export async function createState(
10+
options: InternalOptions<"oauth">
11+
): Promise<{ cookie: Cookie; value: string } | undefined> {
12+
const { logger, provider, jwt, cookies } = options
713

814
if (!provider.checks?.includes("state")) {
915
// Provider does not support state, return nothing
1016
return
1117
}
1218

13-
if (!csrfToken) {
14-
logger.warn("NO_CSRF_TOKEN")
15-
return
16-
}
19+
const state = generators.state()
20+
21+
const encodedState = await jwt.encode({
22+
...jwt,
23+
maxAge: STATE_MAX_AGE,
24+
token: { state },
25+
})
1726

18-
// A hash of the NextAuth.js CSRF token is used as the state
19-
const state = createHash("sha256").update(csrfToken).digest("hex")
27+
logger.debug("CREATE_STATE", { state, maxAge: STATE_MAX_AGE })
2028

21-
logger.debug("OAUTH_CALLBACK_PROTECTION", { state, csrfToken })
22-
return state
29+
const expires = new Date()
30+
expires.setTime(expires.getTime() + STATE_MAX_AGE * 1000)
31+
return {
32+
value: state,
33+
cookie: {
34+
name: cookies.state.name,
35+
value: encodedState,
36+
options: { ...cookies.state.options, expires },
37+
},
38+
}
2339
}
2440

2541
/**
26-
* Consistently recreate state from the csrfToken
27-
* if `provider.checks` supports `"state"`.
42+
* Returns state from if the provider supports states,
43+
* and clears the container cookie afterwards.
2844
*/
29-
export function getState({ provider, csrfToken }: InternalOptions<"oauth">) {
30-
if (provider?.checks?.includes("state") && csrfToken) {
31-
return createHash("sha256").update(csrfToken).digest("hex")
45+
export async function useState(
46+
state: string | undefined,
47+
options: InternalOptions<"oauth">
48+
): Promise<{ value: string; cookie: Cookie } | undefined> {
49+
const { cookies, provider, jwt } = options
50+
51+
if (!provider.checks?.includes("state") || !state) return
52+
53+
const value = (await jwt.decode({ ...options.jwt, token: state })) as any
54+
55+
return {
56+
value: value?.state ?? undefined,
57+
cookie: {
58+
name: cookies.state.name,
59+
value: "",
60+
options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 },
61+
},
3262
}
3363
}

0 commit comments

Comments
 (0)