Skip to content

Commit ea71a1f

Browse files
committed
feat: add PKCE support (#941)
* chore(deps): upgrade dependencies * chore(deps): add pkce-challenge * feat(pkce): initial implementation of PCKE support * chore: remove URLSearchParams * chore(deps): upgrade lockfile * refactor: store code_verifier in a cookie * refactor: add pkce handlers * docs: add PKCE documentation * chore: remove unused param * chore: revert unnecessary code change * fix: correct variable names
1 parent 0069095 commit ea71a1f

File tree

13 files changed

+1852
-599
lines changed

13 files changed

+1852
-599
lines changed

package-lock.json

+1,728-592
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"jsonwebtoken": "^8.5.1",
4949
"nodemailer": "^6.4.16",
5050
"oauth": "^0.9.15",
51+
"pkce-challenge": "^2.1.0",
5152
"preact": "^10.4.1",
5253
"preact-render-to-string": "^5.1.7",
5354
"querystring": "^0.2.0",

pages/api/auth/[...nextauth].js

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ export default NextAuth({
88
clientId: process.env.GITHUB_ID,
99
clientSecret: process.env.GITHUB_SECRET,
1010
}),
11+
Providers.Auth0({
12+
clientId: process.env.AUTH0_ID,
13+
clientSecret: process.env.AUTH0_SECRET,
14+
domain: process.env.AUTH0_DOMAIN,
15+
protection: "pkce"
16+
})
1117
],
1218
// Database optional. MySQL, Maria DB, Postgres and MongoDB are supported.
1319
// https://next-auth.js.org/configuration/databases

src/server/index.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as routes from './routes'
1212
import renderPage from './pages'
1313
import csrfTokenHandler from './lib/csrf-token-handler'
1414
import createSecret from './lib/create-secret'
15+
import * as pkce from './lib/pkce-handler'
1516

1617
// To work properly in production with OAuth providers the NEXTAUTH_URL
1718
// environment variable must be set.
@@ -112,7 +113,8 @@ async function NextAuthHandler (req, res, userOptions) {
112113
callbacks: {
113114
...defaultCallbacks,
114115
...userOptions.callbacks
115-
}
116+
},
117+
pkce: {}
116118
}
117119

118120
await callbackUrlHandler(req, res)
@@ -143,6 +145,9 @@ async function NextAuthHandler (req, res, userOptions) {
143145
return render.signout()
144146
case 'callback':
145147
if (provider) {
148+
const error = await pkce.handleCallback(req, res)
149+
if (error) return res.redirect(error)
150+
146151
return routes.callback(req, res)
147152
}
148153
break
@@ -179,6 +184,9 @@ async function NextAuthHandler (req, res, userOptions) {
179184
case 'signin':
180185
// Verified CSRF Token required for all sign in routes
181186
if (csrfTokenVerified && provider) {
187+
const error = await pkce.handleSignin(req, res)
188+
if (error) return res.redirect(error)
189+
182190
return routes.signin(req, res)
183191
}
184192

@@ -196,6 +204,9 @@ async function NextAuthHandler (req, res, userOptions) {
196204
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
197205
}
198206

207+
const error = await pkce.handleCallback(req, res)
208+
if (error) return res.redirect(error)
209+
199210
return routes.callback(req, res)
200211
}
201212
break

src/server/lib/cookie.js

+9
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ export function defaultCookies (useSecureCookies) {
142142
path: '/',
143143
secure: useSecureCookies
144144
}
145+
},
146+
pkceCodeVerifier: {
147+
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
148+
options: {
149+
httpOnly: true,
150+
sameSite: 'lax',
151+
path: '/',
152+
secure: useSecureCookies
153+
}
145154
}
146155
}
147156
}

src/server/lib/oauth/callback.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class OAuthCallbackError extends Error {
1111
}
1212

1313
export default async function oAuthCallback (req) {
14-
const { provider, csrfToken } = req.options
14+
const { provider, csrfToken, pkce } = req.options
1515
const client = oAuthClient(provider)
1616

1717
if (provider.version?.startsWith('2.')) {
@@ -53,7 +53,7 @@ export default async function oAuthCallback (req) {
5353
}
5454

5555
try {
56-
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(code, provider)
56+
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(code, provider, pkce.code_verifier)
5757
const tokens = { accessToken, refreshToken, idToken: results.id_token }
5858
let profileData
5959
if (provider.idToken) {

src/server/lib/oauth/client.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export default function oAuthClient (provider) {
8787
/**
8888
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
8989
*/
90-
async function getOAuth2AccessToken (code, provider) {
90+
async function getOAuth2AccessToken (code, provider, codeVerifier) {
9191
const url = provider.accessTokenUrl
9292
const params = { ...provider.params }
9393
const headers = { ...provider.headers }
@@ -132,6 +132,10 @@ async function getOAuth2AccessToken (code, provider) {
132132
headers.Authorization = `Bearer ${code}`
133133
}
134134

135+
if (provider.protection === 'pkce') {
136+
params.code_verifier = codeVerifier
137+
}
138+
135139
const postData = querystring.stringify(params)
136140

137141
return new Promise((resolve, reject) => {

src/server/lib/pkce-handler.js

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pkceChallenge from 'pkce-challenge'
2+
import jwt from '../../lib/jwt'
3+
import * as cookie from '../lib/cookie'
4+
import logger from 'src/lib/logger'
5+
6+
const PKCE_LENGTH = 64
7+
const PKCE_CODE_CHALLENGE_METHOD = 'S256' // can be 'plain', not recommended https://tools.ietf.org/html/rfc7636#section-4.2
8+
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
9+
10+
/** Adds `code_verifier` to `req.options.pkce`, and removes the corresponding cookie */
11+
export async function handleCallback (req, res) {
12+
const { cookies, provider, baseUrl, basePath } = req.options
13+
try {
14+
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
15+
return
16+
}
17+
18+
if (!(cookies.pkceCodeVerifier.name in req.cookies)) {
19+
throw new Error('The code_verifier cookie was not found.')
20+
}
21+
const pkce = await jwt.decode({
22+
...req.options.jwt,
23+
token: req.cookies[cookies.pkceCodeVerifier.name],
24+
maxAge: PKCE_MAX_AGE,
25+
encryption: true
26+
})
27+
cookie.set(res, cookies.pkceCodeVerifier.name, null, { maxAge: 0 }) // remove PKCE after it has been used
28+
req.options.pkce = pkce
29+
} catch (error) {
30+
logger.error('PKCE_ERROR', error)
31+
return `${baseUrl}${basePath}/error?error=Configuration`
32+
}
33+
}
34+
35+
/** Adds `code_challenge` and `code_challenge_method` to `req.options.pkce`. */
36+
export async function handleSignin (req, res) {
37+
const { cookies, provider, baseUrl, basePath } = req.options
38+
try {
39+
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
40+
return
41+
}
42+
// Started login flow, add generated pkce to req.options and (encrypted) code_verifier to a cookie
43+
const pkce = pkceChallenge(PKCE_LENGTH)
44+
req.options.pkce = {
45+
code_challenge: pkce.code_challenge,
46+
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD
47+
}
48+
const encryptedCodeVerifier = await jwt.encode({
49+
...req.options.jwt,
50+
maxAge: PKCE_MAX_AGE,
51+
token: { code_verifier: pkce.code_verifier },
52+
encryption: true
53+
})
54+
55+
const cookieExpires = new Date()
56+
cookieExpires.setTime(cookieExpires.getTime() + (PKCE_MAX_AGE * 1000))
57+
cookie.set(res, cookies.pkceCodeVerifier.name, encryptedCodeVerifier, {
58+
expires: cookieExpires.toISOString(),
59+
...cookies.pkceCodeVerifier.options
60+
})
61+
} catch (error) {
62+
logger.error('PKCE_ERROR', error)
63+
return `${baseUrl}${basePath}/error?error=Configuration`
64+
}
65+
}
66+
67+
export default {
68+
handleSignin, handleCallback
69+
}

src/server/lib/signin/oauth.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { createHash } from 'crypto'
33
import logger from '../../../lib/logger'
44

55
export default async function getAuthorizationUrl (req) {
6-
const { provider, csrfToken } = req.options
6+
const { provider, csrfToken, pkce } = req.options
77

88
const client = oAuthClient(provider)
99
if (provider.version?.startsWith('2.')) {
1010
// Handle OAuth v2.x
1111
let url = client.getAuthorizeUrl({
1212
...provider.authorizationParams,
1313
...req.body.authorizationParams,
14+
...pkce,
1415
redirect_uri: provider.callbackUrl,
1516
scope: provider.scope,
1617
// A hash of the NextAuth.js CSRF token is used as the state

src/server/routes/signin.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export default async function signin (req, res) {
1818

1919
if (provider.type === 'oauth' && req.method === 'POST') {
2020
try {
21-
const authorizazionUrl = await getAuthorizationUrl(req)
22-
return res.redirect(authorizazionUrl)
21+
const authorizationUrl = await getAuthorizationUrl(req)
22+
return res.redirect(authorizationUrl)
2323
} catch (error) {
2424
logger.error('SIGNIN_OAUTH_ERROR', error)
2525
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)

www/docs/configuration/options.md

+9
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,15 @@ cookies: {
395395
path: '/',
396396
secure: true
397397
}
398+
},
399+
pkceCodeVerifier: {
400+
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
401+
options: {
402+
httpOnly: true,
403+
sameSite: 'lax',
404+
path: '/',
405+
secure: useSecureCookies
406+
}
398407
}
399408
}
400409
```

www/docs/configuration/providers.md

+1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ providers: [
132132
| profile | An callback returning an object with the user's info | `object` | No |
133133
| idToken | Set to `true` for services that use ID Tokens (e.g. OpenID) | `boolean` | No |
134134
| headers | Any headers that should be sent to the OAuth provider | `object` | No |
135+
| protection | Additional security for OAuth login flows | `pkce` | No |
135136

136137
## Sign in with Email
137138

www/docs/errors.md

+6
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ In _most cases_ it does not make sense to specify a database in NextAuth.js opti
7171

7272
#### CALLBACK_CREDENTIALS_HANDLER_ERROR
7373

74+
#### PKCE_ERROR
75+
76+
The provider you tried to use failed when setting [PKCE or Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636#section-4.2).
77+
The `code_verifier` is saved in a cookie called (by default) `__Secure-next-auth.pkce.code_verifier` which expires after 15 minutes.
78+
Check if `cookies.pkceCodeVerifier` is configured correctly. The default `code_challenge_method` is `"S256"`. This is currently not configurable to `"plain"`, as it is not recommended, and in most cases it is only supported for backward compatibility.
79+
7480
---
7581

7682
### Session Handling

0 commit comments

Comments
 (0)