Skip to content

feat: add PKCE support #941

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

Merged
merged 12 commits into from
Jan 20, 2021
Merged
3,427 changes: 2,068 additions & 1,359 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@
"jwt-decode": "^2.2.0",
"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",
"require_optional": "^1.0.1",
"typeorm": "^0.2.24"
"typeorm": "^0.2.29"
},
"peerDependencies": {
"react": "^16.13.1 || ^17",
Expand All @@ -68,9 +69,9 @@
"@prisma/client": "^2.12.0"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@babel/cli": "^7.12.8",
"@babel/core": "^7.12.9",
"@babel/preset-env": "^7.12.7",
"autoprefixer": "^9.7.6",
"babel-preset-preact": "^2.0.0",
"cssnano": "^4.1.10",
Expand Down
9 changes: 6 additions & 3 deletions src/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import session from './routes/session'
import pages from './pages'
import adapters from '../adapters'
import logger from '../lib/logger'
import pkceChallenge from 'pkce-challenge'

// To work properly in production with OAuth providers the NEXTAUTH_URL
// environment variable must be set.
Expand Down Expand Up @@ -217,6 +218,8 @@ async function NextAuth (req, res, userSuppliedOptions) {
redirect
}

const pkce = options.providers[provider]?.pkce ? pkceChallenge(64) : undefined
Copy link
Member Author

@balazsorban44 balazsorban44 Dec 9, 2020

Choose a reason for hiding this comment

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

Length of 64 was arbitrarily chosen here. The requirement by the spec is 43-128 characters.

Also, this is created for the entire app, meaning this will be the same for all the users. This makes the whole feature much less complicated, but I am not sure if this brings any security issues. If I remember our chat earlier @iaincollins, you said that in theory, we wouldn't even need to care about PKCE. Am I right, or we should find a way to create this per user?

The problem is that in that case, the values must be saved somewhere, so the challenge and verifier can be passed to both /authorize and /token respectively

Choose a reason for hiding this comment

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

In terms of saving the challenge and verifier, it could be saved in local storage (maybe session) to be cleaned up after a successful log in.

Copy link
Member Author

@balazsorban44 balazsorban44 Dec 15, 2020

Choose a reason for hiding this comment

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

I am not sure we would like to store anything sensitive this way, cause it is perceptible with potentially malicious code on the client. If I understand it correctly, sessionStorage would be a better option in this cause, although I am still not sure it is good enough. Going to discuss it with @iaincollins hopefully this weekend, I hope he has some insights as well.

Maybe the way you describe it IS the way we have to go, we will see. Thanks for weighing in!

Copy link

@mikecabana mikecabana Dec 15, 2020

Choose a reason for hiding this comment

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

No problem! I inspired myself from oidc-client-js which I'm currently using in a production spa. They use localStorage for the code_verifier and sessionStorage for the tokens.

Via the spec every new authorization flow should use a new code_verifier so this could be why they've opted to go this route. I'm not really sure either what kind of security concerns this method raises.

In any case I've set up a simple sample to generate the code_verifier and code_challenge in the browser. I don't think it's perfect but it can help to get you started. https://gist.github.com/mikecabana/9cae8b447afd2a36da5d38b94bfc2565

Copy link
Member Author

Choose a reason for hiding this comment

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

In theory we wouldn't actually need PKCE to be secure I think, since our secrets are all server-side and never accessed by the client. So this is why I went with my implementation. Although there might be something important I leave out. We will have a talk about this soon.

Copy link
Member

Choose a reason for hiding this comment

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

@balazsorban44 FWIW for the record this is exactly my current understanding as well.


// If debug enabled, set ENV VAR so that logger logs debug messages
if (options.debug === true) { process.env._NEXTAUTH_DEBUG = true }

Expand Down Expand Up @@ -250,7 +253,7 @@ async function NextAuth (req, res, userSuppliedOptions) {
break
case 'callback':
if (provider && options.providers[provider]) {
callback(req, res, options, done)
callback(req, res, options, done, pkce?.code_verifier)
} else {
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
return done()
Expand Down Expand Up @@ -279,7 +282,7 @@ async function NextAuth (req, res, userSuppliedOptions) {
}

if (provider && options.providers[provider]) {
signin(req, res, options, done)
signin({ req, res, options, done, codeChallenge: pkce.code_challenge })
}
break
case 'signout':
Expand All @@ -297,7 +300,7 @@ async function NextAuth (req, res, userSuppliedOptions) {
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}

callback(req, res, options, done)
callback(req, res, options, done, pkce?.code_verifier)
} else {
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
return done()
Expand Down
21 changes: 13 additions & 8 deletions src/server/lib/oauth/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,22 @@ import logger from '../../../lib/logger'

// @TODO Refactor to use promises and not callbacks
// @TODO Refactor to use jsonwebtoken instead of jwt-decode & remove dependancy
export default async (req, provider, csrfToken, callback) => {
export default async function oAuthCallback (req, provider, csrfToken, callback, codeVerifier) {
// The "user" object is specific to apple provider and is provided on first sign in
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"[email protected]"}
let { oauth_token, oauth_verifier, code, user, state } = req.query // eslint-disable-line camelcase
const client = oAuthClient(provider)

if (provider.version && provider.version.startsWith('2.')) {
if (provider.version?.startsWith('2.')) {
// For OAuth 2.0 flows, check state returned and matches expected value
// (a hash of the NextAuth.js CSRF token).
//
// This check can be disabled for providers that do not support it by
// setting `state: false` as a option on the provider (defaults to true).
// setting `state: false` as an option on the provider (defaults to true).
if (!Object.prototype.hasOwnProperty.call(provider, 'state') || provider.state === true) {
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
if (state !== expectedState) {
return callback(new Error('Invalid state returned from oAuth provider'))
return callback(new Error('Invalid state returned from OAuth provider'))
}
}

Expand Down Expand Up @@ -87,7 +87,7 @@ export default async (req, provider, csrfToken, callback) => {
}
)
} else {
// Use custom get() method for oAuth2 flows
// Use custom get() method for OAuth2 flows
client.get = _get

client.get(
Expand All @@ -100,7 +100,8 @@ export default async (req, provider, csrfToken, callback) => {
}
)
}
}
},
codeVerifier
)
} else {
// Handle oAuth v1.x
Expand Down Expand Up @@ -187,13 +188,17 @@ async function _getProfile (error, profileData, accessToken, refreshToken, provi
}

// Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
async function _getOAuthAccessToken (code, provider, callback) {
async function _getOAuthAccessToken (code, provider, callback, codeVerifier) {
const url = provider.accessTokenUrl
const setGetAccessTokenAuthHeader = (provider.setGetAccessTokenAuthHeader !== null) ? provider.setGetAccessTokenAuthHeader : true
const params = { ...provider.params } || {}
const params = new URLSearchParams({ ...provider.params } || {})
const headers = { ...provider.headers } || {}
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'

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

if (!params[codeParam]) { params[codeParam] = code }

if (!params.client_id) { params.client_id = provider.clientId }
Expand Down
8 changes: 4 additions & 4 deletions src/server/lib/oauth/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
// would be easier to maintain if all the code was native to next-auth.
import { OAuth, OAuth2 } from 'oauth'

export default (provider) => {
if (provider.version && provider.version.startsWith('2.')) {
// Handle oAuth v2.x
export default function oAuthClient (provider) {
if (provider.version?.startsWith('2.')) {
// Handle OAuth v2.x
const basePath = new URL(provider.authorizationUrl).origin
const authorizePath = new URL(provider.authorizationUrl).pathname
const accessTokenPath = new URL(provider.accessTokenUrl).pathname
Expand All @@ -17,7 +17,7 @@ export default (provider) => {
accessTokenPath,
provider.headers)
} else {
// Handle oAuth v1.x
// Handle OAuth v1.x
return new OAuth(
provider.requestTokenUrl,
provider.accessTokenUrl,
Expand Down
23 changes: 14 additions & 9 deletions src/server/lib/signin/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ import oAuthClient from '../oauth/client'
import { createHash } from 'crypto'
import logger from '../../../lib/logger'

export default (provider, csrfToken, callback, authParams) => {
export default function oAuthSignin ({ provider, csrfToken, callback, authParams, codeChallenge }) {
const { callbackUrl } = provider
const client = oAuthClient(provider)
if (provider.version && provider.version.startsWith('2.')) {
// Handle oAuth v2.x
let url = client.getAuthorizeUrl({
if (provider.version?.startsWith('2.')) {
// Handle OAuth v2.x
let url = new URL(client.getAuthorizeUrl({
...authParams,
redirect_uri: provider.callbackUrl,
scope: provider.scope,
// A hash of the NextAuth.js CSRF token is used as the state
state: createHash('sha256').update(csrfToken).digest('hex'),
...provider.additionalAuthorizeParams
})
}))

// If the authorizationUrl specified in the config has query parameters on it
// make sure they are included in the URL we return.
Expand All @@ -24,14 +24,19 @@ export default (provider, csrfToken, callback, authParams) => {
//
// https://github.com/ciaranj/node-oauth/pull/193
if (provider.authorizationUrl.includes('?')) {
const parseUrl = new URL(provider.authorizationUrl)
const baseUrl = `${parseUrl.origin}${parseUrl.pathname}?`
url = url.replace(baseUrl, provider.authorizationUrl + '&')
const providerParams = new URLSearchParams(provider.authorizationUrl.split('?')[1])
const newParams = { ...url.searchParams, ...providerParams }
url = new URL(newParams.toString(), url)
}

if (provider.pkce) {
url.searchParams.append('code_challenge', codeChallenge)
url.searchParams.append('code_challenge_method', 'S256')
}

callback(null, url)
} else {
// Handle oAuth v1.x
// Handle OAuth v1.x
client.getOAuthRequestToken((error, oAuthToken) => {
if (error) {
logger.error('GET_AUTHORISATION_URL_ERROR', error)
Expand Down
4 changes: 2 additions & 2 deletions src/server/routes/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import cookie from '../lib/cookie'
import logger from '../../lib/logger'
import dispatchEvent from '../lib/dispatch-event'

export default async (req, res, options, done) => {
export default async function callback (req, res, options, done, codeVerifier) {
const {
provider: providerName,
providers,
Expand Down Expand Up @@ -127,7 +127,7 @@ export default async (req, res, options, done) => {
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
}
})
}, codeVerifier)
} catch (error) {
logger.error('OAUTH_CALLBACK_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
Expand Down
23 changes: 14 additions & 9 deletions src/server/routes/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import oAuthSignin from '../lib/signin/oauth'
import emailSignin from '../lib/signin/email'
import logger from '../../lib/logger'

export default async (req, res, options, done) => {
export default async function signin ({ req, res, options, done, codeChallenge }) {
const {
provider: providerName,
providers,
Expand All @@ -26,14 +26,19 @@ export default async (req, res, options, done) => {
const authParams = { ...req.query }
delete authParams.nextauth // This is probably not intended to be sent to the provider, remove

oAuthSignin(provider, csrfToken, (error, oAuthSigninUrl) => {
if (error) {
logger.error('SIGNIN_OAUTH_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
}

return redirect(oAuthSigninUrl)
}, authParams)
oAuthSignin({
provider,
csrfToken,
callback (error, oAuthSigninUrl) {
if (error) {
logger.error('SIGNIN_OAUTH_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
}
return redirect(oAuthSigninUrl)
},
authParams,
codeChallenge
})
} else if (type === 'email' && req.method === 'POST') {
if (!adapter) {
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
Expand Down