Skip to content

Commit f277989

Browse files
authoredDec 1, 2022
feat(core): make pkce and state maxAge configurable on the cookies (#4719)
* feat(cookies): make pkce and state maxAge configurable on the cookies (#4660) * added tests for pkce and state handlers
1 parent 6146e93 commit f277989

File tree

6 files changed

+287
-7
lines changed

6 files changed

+287
-7
lines changed
 

‎docs/docs/configuration/options.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,8 @@ cookies: {
472472
httpOnly: true,
473473
sameSite: 'lax',
474474
path: '/',
475-
secure: useSecureCookies
475+
secure: useSecureCookies,
476+
maxAge: 900
476477
}
477478
},
478479
state: {
@@ -482,6 +483,7 @@ cookies: {
482483
sameSite: "lax",
483484
path: "/",
484485
secure: useSecureCookies,
486+
maxAge: 900
485487
},
486488
},
487489
nonce: {

‎packages/next-auth/src/core/lib/cookie.ts

+2
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
9393
sameSite: "lax",
9494
path: "/",
9595
secure: useSecureCookies,
96+
maxAge: 60 * 15, // 15 minutes in seconds
9697
},
9798
},
9899
state: {
@@ -102,6 +103,7 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
102103
sameSite: "lax",
103104
path: "/",
104105
secure: useSecureCookies,
106+
maxAge: 60 * 15, // 15 minutes in seconds
105107
},
106108
},
107109
nonce: {

‎packages/next-auth/src/core/lib/oauth/pkce-handler.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,23 @@ export async function createPKCE(options: InternalOptions<"oauth">): Promise<
2626
const code_verifier = generators.codeVerifier()
2727
const code_challenge = generators.codeChallenge(code_verifier)
2828

29+
const maxAge = cookies.pkceCodeVerifier.options.maxAge ?? PKCE_MAX_AGE
30+
2931
const expires = new Date()
30-
expires.setTime(expires.getTime() + PKCE_MAX_AGE * 1000)
32+
expires.setTime(expires.getTime() + maxAge * 1000)
3133

3234
// Encrypt code_verifier and save it to an encrypted cookie
3335
const encryptedCodeVerifier = await jwt.encode({
3436
...options.jwt,
35-
maxAge: PKCE_MAX_AGE,
37+
maxAge,
3638
token: { code_verifier },
3739
})
3840

3941
logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", {
4042
code_challenge,
4143
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
4244
code_verifier,
43-
PKCE_MAX_AGE,
45+
maxAge,
4446
})
4547

4648
return {

‎packages/next-auth/src/core/lib/oauth/state-handler.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@ export async function createState(
1717
}
1818

1919
const state = generators.state()
20+
const maxAge = cookies.state.options.maxAge ?? STATE_MAX_AGE
2021

2122
const encodedState = await jwt.encode({
2223
...jwt,
23-
maxAge: STATE_MAX_AGE,
24+
maxAge,
2425
token: { state },
2526
})
2627

27-
logger.debug("CREATE_STATE", { state, maxAge: STATE_MAX_AGE })
28+
logger.debug("CREATE_STATE", { state, maxAge })
2829

2930
const expires = new Date()
30-
expires.setTime(expires.getTime() + STATE_MAX_AGE * 1000)
31+
expires.setTime(expires.getTime() + maxAge * 1000)
3132
return {
3233
value: state,
3334
cookie: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { mockLogger } from "./lib"
2+
import type { InternalOptions, LoggerInstance, InternalProvider, CallbacksOptions, Account, Awaitable, Profile, Session, User, CookiesOptions } from "../src"
3+
import { createPKCE } from "../src/core/lib/oauth/pkce-handler"
4+
import { InternalUrl } from "../src/utils/parse-url"
5+
import { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "../src/jwt"
6+
import { CredentialInput } from "../src/providers"
7+
8+
let logger: LoggerInstance
9+
let url: InternalUrl
10+
let provider: InternalProvider<"oauth">
11+
let jwt: JWTOptions
12+
let callbacks: CallbacksOptions
13+
let cookies: CookiesOptions
14+
let options: InternalOptions<"oauth">
15+
16+
beforeEach(() => {
17+
logger = mockLogger()
18+
19+
url = {
20+
origin: "http://localhost:3000",
21+
host: "localhost:3000",
22+
path: "/api/auth",
23+
base: "http://localhost:3000/api/auth",
24+
toString: () => "http://localhost:3000/api/auth"
25+
}
26+
27+
provider = {
28+
type: "oauth",
29+
id: "testId",
30+
name: "testName",
31+
signinUrl: "/",
32+
callbackUrl: "/",
33+
checks: ["pkce", "state"]
34+
}
35+
36+
jwt = {
37+
secret: "secret",
38+
maxAge: 0,
39+
encode: function (params: JWTEncodeParams): Awaitable<string> {
40+
throw new Error("Function not implemented.")
41+
},
42+
decode: function (params: JWTDecodeParams): Awaitable<JWT | null> {
43+
throw new Error("Function not implemented.")
44+
}
45+
}
46+
47+
callbacks = {
48+
signIn: function (params: { user: User; account: Account; profile: Profile & Record<string, unknown>; email: { verificationRequest?: boolean | undefined }; credentials?: Record<string, CredentialInput> | undefined }): Awaitable<string | boolean> {
49+
throw new Error("Function not implemented.")
50+
},
51+
redirect: function (params: { url: string; baseUrl: string }): Awaitable<string> {
52+
throw new Error("Function not implemented.")
53+
},
54+
session: function (params: { session: Session; user: User; token: JWT }): Awaitable<Session> {
55+
throw new Error("Function not implemented.")
56+
},
57+
jwt: function (params: { token: JWT; user?: User | undefined; account?: Account | undefined; profile?: Profile | undefined; isNewUser?: boolean | undefined }): Awaitable<JWT> {
58+
throw new Error("Function not implemented.")
59+
}
60+
}
61+
62+
cookies = {
63+
sessionToken: { name: "", options: undefined },
64+
callbackUrl: { name: "", options: undefined },
65+
csrfToken: { name: "", options: undefined },
66+
pkceCodeVerifier: { name: "", options: {} },
67+
state: { name: "", options: undefined },
68+
nonce: { name: "", options: undefined }
69+
}
70+
71+
options = {
72+
url,
73+
action: "session",
74+
provider,
75+
secret: "",
76+
debug: false,
77+
logger,
78+
session: { strategy: "jwt", maxAge: 0, updateAge: 0 },
79+
pages: {},
80+
jwt,
81+
events: {},
82+
callbacks,
83+
cookies,
84+
callbackUrl: '',
85+
providers: [],
86+
theme: {}
87+
}
88+
})
89+
90+
describe("createPKCE", () => {
91+
it("returns a code challenge, code challenge method, and cookie", async () => {
92+
const pkce = await createPKCE(options)
93+
94+
expect(pkce?.code_challenge).not.toBeNull()
95+
expect(pkce?.code_challenge_method).toEqual("S256")
96+
expect(pkce?.cookie).not.toBeNull()
97+
})
98+
it("does not return a pkce when the provider does not support pkce", async () => {
99+
options.provider.checks = ["state"]
100+
101+
const pkce = await createPKCE(options)
102+
103+
expect(pkce).toBeUndefined()
104+
})
105+
it("sets the cookie expiration to a default of 15 minutes when the max age option is not provided", async () => {
106+
const pkce = await createPKCE(options)
107+
108+
const defaultMaxAge = 60 * 15 // 15 minutes in seconds
109+
const expires = new Date()
110+
expires.setTime(expires.getTime() + defaultMaxAge * 1000)
111+
112+
validateCookieExpiration({pkce, expires})
113+
expect(pkce?.cookie.options.maxAge).toBeUndefined()
114+
})
115+
116+
it("sets the cookie expiration and max age to the provided max age from the options", async () => {
117+
const maxAge = 60 * 20 // 20 minutes
118+
cookies.pkceCodeVerifier.options.maxAge = maxAge
119+
120+
const pkce = await createPKCE(options)
121+
122+
const expires = new Date()
123+
expires.setTime(expires.getTime() + maxAge * 1000)
124+
125+
validateCookieExpiration({pkce, expires})
126+
expect(pkce?.cookie.options.maxAge).toEqual(maxAge)
127+
})
128+
})
129+
130+
// comparing the parts instead of getTime() because the milliseconds
131+
// will not match since the two Date objects are created milliseconds apart
132+
const validateCookieExpiration = ({pkce, expires}) => {
133+
const cookieExpires = pkce?.cookie.options.expires
134+
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
135+
expect(cookieExpires.getMonth()).toEqual(expires.getMonth())
136+
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
137+
expect(cookieExpires.getHours()).toEqual(expires.getHours())
138+
expect(cookieExpires.getMinutes()).toEqual(expires.getMinutes())
139+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { mockLogger } from "./lib"
2+
import type { InternalOptions, LoggerInstance, InternalProvider, CallbacksOptions, Account, Awaitable, Profile, Session, User, CookiesOptions } from "../src"
3+
import { createState } from "../src/core/lib/oauth/state-handler"
4+
import { InternalUrl } from "../src/utils/parse-url"
5+
import { JWT, JWTOptions, encode, decode } from "../src/jwt"
6+
import { CredentialInput } from "../src/providers"
7+
8+
let logger: LoggerInstance
9+
let url: InternalUrl
10+
let provider: InternalProvider<"oauth">
11+
let jwt: JWTOptions
12+
let callbacks: CallbacksOptions
13+
let cookies: CookiesOptions
14+
let options: InternalOptions<"oauth">
15+
16+
beforeEach(() => {
17+
logger = mockLogger()
18+
19+
url = {
20+
origin: "http://localhost:3000",
21+
host: "localhost:3000",
22+
path: "/api/auth",
23+
base: "http://localhost:3000/api/auth",
24+
toString: () => "http://localhost:3000/api/auth"
25+
}
26+
27+
provider = {
28+
type: "oauth",
29+
id: "testId",
30+
name: "testName",
31+
signinUrl: "/",
32+
callbackUrl: "/",
33+
checks: ["pkce", "state"]
34+
}
35+
36+
jwt = {
37+
secret: "secret",
38+
maxAge: 0,
39+
encode,
40+
decode
41+
}
42+
43+
callbacks = {
44+
signIn: function (params: { user: User; account: Account; profile: Profile & Record<string, unknown>; email: { verificationRequest?: boolean | undefined }; credentials?: Record<string, CredentialInput> | undefined }): Awaitable<string | boolean> {
45+
throw new Error("Function not implemented.")
46+
},
47+
redirect: function (params: { url: string; baseUrl: string }): Awaitable<string> {
48+
throw new Error("Function not implemented.")
49+
},
50+
session: function (params: { session: Session; user: User; token: JWT }): Awaitable<Session> {
51+
throw new Error("Function not implemented.")
52+
},
53+
jwt: function (params: { token: JWT; user?: User | undefined; account?: Account | undefined; profile?: Profile | undefined; isNewUser?: boolean | undefined }): Awaitable<JWT> {
54+
throw new Error("Function not implemented.")
55+
}
56+
}
57+
58+
cookies = {
59+
sessionToken: { name: "", options: undefined },
60+
callbackUrl: { name: "", options: undefined },
61+
csrfToken: { name: "", options: undefined },
62+
pkceCodeVerifier: { name: "", options: undefined },
63+
state: { name: "", options: {} },
64+
nonce: { name: "", options: undefined }
65+
}
66+
67+
options = {
68+
url,
69+
action: "session",
70+
provider,
71+
secret: "",
72+
debug: false,
73+
logger,
74+
session: { strategy: "jwt", maxAge: 0, updateAge: 0 },
75+
pages: {},
76+
jwt,
77+
events: {},
78+
callbacks,
79+
cookies,
80+
callbackUrl: '',
81+
providers: [],
82+
theme: {}
83+
}
84+
})
85+
86+
describe("createState", () => {
87+
it("returns a state, and cookie", async () => {
88+
const state = await createState(options)
89+
90+
expect(state?.value).not.toBeNull()
91+
expect(state?.cookie).not.toBeNull()
92+
})
93+
it("does not return a state when the provider does not support state", async () => {
94+
options.provider.checks = ["pkce"]
95+
96+
const state = await createState(options)
97+
98+
expect(state).toBeUndefined()
99+
})
100+
it("sets the cookie expiration to a default of 15 minutes when the max age option is not provided", async () => {
101+
const state = await createState(options)
102+
103+
const defaultMaxAge = 60 * 15 // 15 minutes in seconds
104+
const expires = new Date()
105+
expires.setTime(expires.getTime() + defaultMaxAge * 1000)
106+
107+
validateCookieExpiration({state, expires})
108+
expect(state?.cookie.options.maxAge).toBeUndefined()
109+
})
110+
111+
it("sets the cookie expiration and max age to the provided max age from the options", async () => {
112+
const maxAge = 60 * 20 // 20 minutes
113+
cookies.state.options.maxAge = maxAge
114+
115+
const state = await createState(options)
116+
117+
const expires = new Date()
118+
expires.setTime(expires.getTime() + maxAge * 1000)
119+
120+
validateCookieExpiration({state, expires})
121+
expect(state?.cookie.options.maxAge).toEqual(maxAge)
122+
})
123+
})
124+
125+
// comparing the parts instead of getTime() because the milliseconds
126+
// will not match since the two Date objects are created milliseconds apart
127+
const validateCookieExpiration = ({state, expires}) => {
128+
const cookieExpires = state?.cookie.options.expires
129+
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
130+
expect(cookieExpires.getMonth()).toEqual(expires.getMonth())
131+
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
132+
expect(cookieExpires.getHours()).toEqual(expires.getHours())
133+
expect(cookieExpires.getMinutes()).toEqual(expires.getMinutes())
134+
}

1 commit comments

Comments
 (1)
Please sign in to comment.