Skip to content

Commit

Permalink
feat: add PKCE support (#941)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
balazsorban44 committed Jan 20, 2021
1 parent 4f93e6a commit 536f0ad
Show file tree
Hide file tree
Showing 13 changed files with 1,852 additions and 599 deletions.
2,320 changes: 1,728 additions & 592 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"jsonwebtoken": "^8.5.1",
"nodemailer": "^6.4.16",
"oauth": "^0.9.15",
"pkce-challenge": "^2.1.0",
"preact": "^10.4.1",
"preact-render-to-string": "^5.1.7",
"querystring": "^0.2.0",
Expand Down
6 changes: 6 additions & 0 deletions pages/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export default NextAuth({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Providers.Auth0({
clientId: process.env.AUTH0_ID,
clientSecret: process.env.AUTH0_SECRET,
domain: process.env.AUTH0_DOMAIN,
protection: "pkce"
})
],
// Database optional. MySQL, Maria DB, Postgres and MongoDB are supported.
// https://next-auth.js.org/configuration/databases
Expand Down
13 changes: 12 additions & 1 deletion src/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as routes from './routes'
import renderPage from './pages'
import csrfTokenHandler from './lib/csrf-token-handler'
import createSecret from './lib/create-secret'
import * as pkce from './lib/pkce-handler'

// To work properly in production with OAuth providers the NEXTAUTH_URL
// environment variable must be set.
Expand Down Expand Up @@ -112,7 +113,8 @@ async function NextAuthHandler (req, res, userOptions) {
callbacks: {
...defaultCallbacks,
...userOptions.callbacks
}
},
pkce: {}
}

await callbackUrlHandler(req, res)
Expand Down Expand Up @@ -143,6 +145,9 @@ async function NextAuthHandler (req, res, userOptions) {
return render.signout()
case 'callback':
if (provider) {
const error = await pkce.handleCallback(req, res)
if (error) return res.redirect(error)

return routes.callback(req, res)
}
break
Expand Down Expand Up @@ -179,6 +184,9 @@ async function NextAuthHandler (req, res, userOptions) {
case 'signin':
// Verified CSRF Token required for all sign in routes
if (csrfTokenVerified && provider) {
const error = await pkce.handleSignin(req, res)
if (error) return res.redirect(error)

return routes.signin(req, res)
}

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

const error = await pkce.handleCallback(req, res)
if (error) return res.redirect(error)

return routes.callback(req, res)
}
break
Expand Down
9 changes: 9 additions & 0 deletions src/server/lib/cookie.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ export function defaultCookies (useSecureCookies) {
path: '/',
secure: useSecureCookies
}
},
pkceCodeVerifier: {
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
}
}
}
4 changes: 2 additions & 2 deletions src/server/lib/oauth/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class OAuthCallbackError extends Error {
}

export default async function oAuthCallback (req) {
const { provider, csrfToken } = req.options
const { provider, csrfToken, pkce } = req.options
const client = oAuthClient(provider)

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

try {
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(code, provider)
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(code, provider, pkce.code_verifier)
const tokens = { accessToken, refreshToken, idToken: results.id_token }
let profileData
if (provider.idToken) {
Expand Down
6 changes: 5 additions & 1 deletion src/server/lib/oauth/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default function oAuthClient (provider) {
/**
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
*/
async function getOAuth2AccessToken (code, provider) {
async function getOAuth2AccessToken (code, provider, codeVerifier) {
const url = provider.accessTokenUrl
const params = { ...provider.params }
const headers = { ...provider.headers }
Expand Down Expand Up @@ -132,6 +132,10 @@ async function getOAuth2AccessToken (code, provider) {
headers.Authorization = `Bearer ${code}`
}

if (provider.protection === 'pkce') {
params.code_verifier = codeVerifier
}

const postData = querystring.stringify(params)

return new Promise((resolve, reject) => {
Expand Down
69 changes: 69 additions & 0 deletions src/server/lib/pkce-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pkceChallenge from 'pkce-challenge'
import jwt from '../../lib/jwt'
import * as cookie from '../lib/cookie'
import logger from 'src/lib/logger'

const PKCE_LENGTH = 64
const PKCE_CODE_CHALLENGE_METHOD = 'S256' // can be 'plain', not recommended https://tools.ietf.org/html/rfc7636#section-4.2
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds

/** Adds `code_verifier` to `req.options.pkce`, and removes the corresponding cookie */
export async function handleCallback (req, res) {
const { cookies, provider, baseUrl, basePath } = req.options
try {
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
return
}

if (!(cookies.pkceCodeVerifier.name in req.cookies)) {
throw new Error('The code_verifier cookie was not found.')
}
const pkce = await jwt.decode({
...req.options.jwt,
token: req.cookies[cookies.pkceCodeVerifier.name],
maxAge: PKCE_MAX_AGE,
encryption: true
})
cookie.set(res, cookies.pkceCodeVerifier.name, null, { maxAge: 0 }) // remove PKCE after it has been used
req.options.pkce = pkce
} catch (error) {
logger.error('PKCE_ERROR', error)
return `${baseUrl}${basePath}/error?error=Configuration`
}
}

/** Adds `code_challenge` and `code_challenge_method` to `req.options.pkce`. */
export async function handleSignin (req, res) {
const { cookies, provider, baseUrl, basePath } = req.options
try {
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
return
}
// Started login flow, add generated pkce to req.options and (encrypted) code_verifier to a cookie
const pkce = pkceChallenge(PKCE_LENGTH)
req.options.pkce = {
code_challenge: pkce.code_challenge,
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD
}
const encryptedCodeVerifier = await jwt.encode({
...req.options.jwt,
maxAge: PKCE_MAX_AGE,
token: { code_verifier: pkce.code_verifier },
encryption: true
})

const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + (PKCE_MAX_AGE * 1000))
cookie.set(res, cookies.pkceCodeVerifier.name, encryptedCodeVerifier, {
expires: cookieExpires.toISOString(),
...cookies.pkceCodeVerifier.options
})
} catch (error) {
logger.error('PKCE_ERROR', error)
return `${baseUrl}${basePath}/error?error=Configuration`
}
}

export default {
handleSignin, handleCallback
}
3 changes: 2 additions & 1 deletion src/server/lib/signin/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { createHash } from 'crypto'
import logger from '../../../lib/logger'

export default async function getAuthorizationUrl (req) {
const { provider, csrfToken } = req.options
const { provider, csrfToken, pkce } = req.options

const client = oAuthClient(provider)
if (provider.version?.startsWith('2.')) {
// Handle OAuth v2.x
let url = client.getAuthorizeUrl({
...provider.authorizationParams,
...req.body.authorizationParams,
...pkce,
redirect_uri: provider.callbackUrl,
scope: provider.scope,
// A hash of the NextAuth.js CSRF token is used as the state
Expand Down
4 changes: 2 additions & 2 deletions src/server/routes/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export default async function signin (req, res) {

if (provider.type === 'oauth' && req.method === 'POST') {
try {
const authorizazionUrl = await getAuthorizationUrl(req)
return res.redirect(authorizazionUrl)
const authorizationUrl = await getAuthorizationUrl(req)
return res.redirect(authorizationUrl)
} catch (error) {
logger.error('SIGNIN_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
Expand Down
9 changes: 9 additions & 0 deletions www/docs/configuration/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,15 @@ cookies: {
path: '/',
secure: true
}
},
pkceCodeVerifier: {
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
}
}
```
Expand Down
1 change: 1 addition & 0 deletions www/docs/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ providers: [
| profile | An callback returning an object with the user's info | `object` | No |
| idToken | Set to `true` for services that use ID Tokens (e.g. OpenID) | `boolean` | No |
| headers | Any headers that should be sent to the OAuth provider | `object` | No |
| protection | Additional security for OAuth login flows | `pkce` | No |

## Sign in with Email

Expand Down
6 changes: 6 additions & 0 deletions www/docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ In _most cases_ it does not make sense to specify a database in NextAuth.js opti

#### CALLBACK_CREDENTIALS_HANDLER_ERROR

#### PKCE_ERROR

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).
The `code_verifier` is saved in a cookie called (by default) `__Secure-next-auth.pkce.code_verifier` which expires after 15 minutes.
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.

---

### Session Handling
Expand Down

0 comments on commit 536f0ad

Please sign in to comment.