Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add PKCE support #941

Merged
merged 12 commits into from
Jan 20, 2021
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",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can probably be removed and we can create our own, added to be able to get started a bit faster

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openid-client has this built-in, we can utilize it when/if we finish #1105

"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