Skip to content

Commit

Permalink
feat: implement PKCE extension for Authorization Code Grant (RFC 7636)
Browse files Browse the repository at this point in the history
  • Loading branch information
big-kahuna-burger committed Nov 21, 2023
1 parent f7db10d commit 3203054
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 21 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ This fastify plugin adds 5 utility decorators to your fastify instance using the
- `refresh_token` (optional, only if the `offline scope` was originally requested, as seen in the callbackUriParams example)
- `token_type` (generally `'Bearer'`)
- `expires_in` (number of seconds for the token to expire, e.g. `240000`)

- OR `getAccessTokenFromAuthorizationCodeFlow(request, reply, callback)` variant with 3 arguments, which should be used when PKCE extension is used.
This allows fastify-oauth2 to delete PKCE code_verifier cookie so it doesn't stay in browser in case server has issue when fetching token. See [Google With PKCE example for more](./examples/google-with-pkce.js).

- `getNewAccessTokenUsingRefreshToken(Token, params, callback)`: A function that takes a `AccessToken`-Object as `Token` and retrieves a new `AccessToken`-Object. This is generally useful with background processing workers to re-issue a new AccessToken when the previous AccessToken has expired. The `params` argument is optional and it is an object that can be used to pass in additional parameters to the refresh request (e.g. a stricter set of scopes). If the callback is not passed this function will return a Promise. The object resulting from the callback call or the resolved Promise is a new `AccessToken` object (see above). Example of how you would use it for `name:googleOAuth2`:
```js
fastify.googleOAuth2.getNewAccessTokenUsingRefreshToken(currentAccessToken, (err, newAccessToken) => {
Expand Down
64 changes: 64 additions & 0 deletions examples/google-with-pkce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict'

const fastify = require('fastify')({ logger: { level: 'trace' } })
const sget = require('simple-get')

const cookieOpts = {
// domain: 'localhost',
path: '/',
secure: true,
sameSite: 'lax',
httpOnly: true
}

// const oauthPlugin = require('fastify-oauth2')
fastify.register(require('@fastify/cookie'), {
secret: ['my-secret'],
parseOptions: cookieOpts
})

const oauthPlugin = require('..')
fastify.register(oauthPlugin, {
name: 'googleOAuth2',
scope: ['openid', 'profile', 'email'],
credentials: {
client: {
id: process.env.CLIENT_ID,
secret: process.env.CLIENT_SECRET
},
auth: oauthPlugin.GOOGLE_CONFIGURATION
},
startRedirectPath: '/login/google',
callbackUri: 'http://localhost:3000/interaction/callback/google',
cookie: cookieOpts,
pkce: 'S256' // "plain" is also supported in this library, see your own provider's .well-known/openid-configuration endpoint (if it's exposed) to understand which methods are supported
})

fastify.get('/interaction/callback/google', function (request, reply) {
// Note that in this example a "reply" is also passed, it's so that code verifier cookie can be cleaned before
// token is requested from token endpoint
this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, (err, result) => {
if (err) {
reply.send(err)
return
}

sget.concat({
url: 'https://www.googleapis.com/oauth2/v2/userinfo',
method: 'GET',
headers: {
Authorization: 'Bearer ' + result.token.access_token
},
json: true
}, function (err, res, data) {
if (err) {
reply.send(err)
return
}
reply.send(data)
})
})
})

fastify.listen({ port: 3000 })
fastify.log.info('go to http://localhost:3000/login/google')
64 changes: 51 additions & 13 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'

const { randomBytes } = require('node:crypto')
const { randomBytes, createHash } = require('node:crypto')

const fp = require('fastify-plugin')
const { AuthorizationCode } = require('simple-oauth2')
Expand All @@ -9,9 +9,18 @@ const kGenerateCallbackUriParams = Symbol.for('fastify-oauth2.generate-callback-
const { promisify, callbackify } = require('node:util')

const USER_AGENT = 'fastify-oauth2'
const VERIFIER_COOKIE_NAME = 'oauth2-code-verifier'
const S256 = 'S256'
const PKCE_METHODS = ['plain', S256]

const b64Encode = (input, encoding = 'utf8') => Buffer.from(input, encoding).toString('base64url')

const random = (bytes = 32) => b64Encode(randomBytes(bytes))
const codeVerifier = random
const codeChallenge = verifier => b64Encode(createHash('sha256').update(verifier).digest())

function defaultGenerateStateFunction () {
return randomBytes(16).toString('base64url')
return random(16).toString('base64url')
}

function defaultCheckStateFunction (request, callback) {
Expand Down Expand Up @@ -74,7 +83,9 @@ function fastifyOauth2 (fastify, options, next) {
if (options.userAgent && typeof options.userAgent !== 'string') {
return next(new Error('options.userAgent should be a string'))
}

if (options.pkce && (typeof options.pkce !== 'string' || !PKCE_METHODS.includes(options.pkce))) {
return next(new Error('options.pkce should be one of "plain" | "S256" when used'))
}
if (!fastify.hasReplyDecorator('cookie')) {
fastify.register(require('@fastify/cookie'))
}
Expand All @@ -89,7 +100,8 @@ function fastifyOauth2 (fastify, options, next) {
checkStateFunction = defaultCheckStateFunction,
startRedirectPath,
tags = [],
schema = { tags }
schema = { tags },
pkce
} = options

const userAgent = options.userAgent === false
Expand All @@ -113,11 +125,23 @@ function fastifyOauth2 (fastify, options, next) {

reply.setCookie('oauth2-redirect-state', state, cookieOpts)

// when PKCE extension is used
let pkceParams = {}
if (pkce) {
const verifier = codeVerifier()
const challenge = pkce === S256 ? codeChallenge(verifier) : verifier
pkceParams = {
code_challenge: challenge,
code_challenge_method: pkce
}
reply.setCookie(VERIFIER_COOKIE_NAME, verifier, cookieOpts)
}

const urlOptions = Object.assign({}, generateCallbackUriParams(callbackUriParams, request, scope, state), {
redirect_uri: callbackUri,
scope,
state
})
}, pkceParams)

return oauth2.authorizeURL(urlOptions)
}
Expand All @@ -128,34 +152,44 @@ function fastifyOauth2 (fastify, options, next) {
reply.redirect(authorizationUri)
}

const cbk = function (o, code, callback) {
const cbk = function (o, code, pkceParams, callback) {
const body = Object.assign({}, tokenRequestParams, {
code,
redirect_uri: callbackUri
})
}, pkceParams)

return callbackify(o.oauth2.getToken.bind(o.oauth2, body))(callback)
}

function getAccessTokenFromAuthorizationCodeFlowCallbacked (request, callback) {
function getAccessTokenFromAuthorizationCodeFlowCallbacked (request, reply, callback) {
const code = request.query.code
const pkceParams = pkce ? { code_verifier: request.cookies['oauth2-code-verifier'] } : {}

const _callback = typeof reply === 'function' ? reply : callback

if (reply && typeof reply !== 'function') {
// cleanup a cookie if plugin user uses (req, res, cb) signature variant of getAccessToken fn
clearCodeVerifierCookie(reply)
}

checkStateFunction(request, function (err) {
if (err) {
callback(err)
return
}
cbk(fastify[name], code, callback)
cbk(fastify[name], code, pkceParams, _callback)
})
}

const getAccessTokenFromAuthorizationCodeFlowPromisified = promisify(getAccessTokenFromAuthorizationCodeFlowCallbacked)

function getAccessTokenFromAuthorizationCodeFlow (request, callback) {
if (!callback) {
return getAccessTokenFromAuthorizationCodeFlowPromisified(request)
function getAccessTokenFromAuthorizationCodeFlow (request, reply, callback) {
const _callback = typeof reply === 'function' ? reply : callback

if (!_callback) {
return getAccessTokenFromAuthorizationCodeFlowPromisified(request, reply)
}
getAccessTokenFromAuthorizationCodeFlowCallbacked(request, callback)
getAccessTokenFromAuthorizationCodeFlowCallbacked(request, reply, _callback)
}

function getNewAccessTokenUsingRefreshTokenCallbacked (refreshToken, params, callback) {
Expand Down Expand Up @@ -200,6 +234,10 @@ function fastifyOauth2 (fastify, options, next) {
revokeAllTokenCallbacked(token, params, callback)
}

function clearCodeVerifierCookie (reply) {
reply.clearCookie(VERIFIER_COOKIE_NAME, cookieOpts)
}

const oauth2 = new AuthorizationCode(credentials)

if (startRedirectPath) {
Expand Down
118 changes: 112 additions & 6 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const fastifyOauth2 = require('..')

nock.disableNetConnect()

function makeRequests (t, fastify, userAgentHeaderMatcher) {
function makeRequests (t, fastify, userAgentHeaderMatcher, pkce) {
fastify.listen({ port: 0 }, function (err) {
t.error(err)

Expand All @@ -19,10 +19,15 @@ function makeRequests (t, fastify, userAgentHeaderMatcher) {
t.error(err)

t.equal(responseStart.statusCode, 302)
const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=notifications&state=(.*)/)
t.ok(matched)
const state = matched[1]

const { searchParams } = new URL(responseStart.headers.location)
const [state, codeChallengeMethod, codeChallenge] = ['state', 'code_challenge_method', 'code_challenge'].map(k => searchParams.get(k))

t.ok(state)
if (pkce) {
t.strictSame(codeChallengeMethod, pkce, 'pkce method must match')
t.ok(codeChallenge, 'code challenge is present')
}

const RESPONSE_BODY = {
access_token: 'my-access-token',
Expand All @@ -41,7 +46,7 @@ function makeRequests (t, fastify, userAgentHeaderMatcher) {
const githubScope = nock('https://github.com')
.matchHeader('Authorization', 'Basic bXktY2xpZW50LWlkOm15LXNlY3JldA==')
.matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2')
.post('/login/oauth/access_token', 'grant_type=authorization_code&code=my-code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback')
.post('/login/oauth/access_token', 'grant_type=authorization_code&code=my-code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback' + (pkce ? '&code_verifier=myverifier' : ''))
.reply(200, RESPONSE_BODY)
.post('/login/oauth/access_token', 'grant_type=refresh_token&refresh_token=my-refresh-token')
.reply(200, RESPONSE_BODY_REFRESHED)
Expand All @@ -50,7 +55,8 @@ function makeRequests (t, fastify, userAgentHeaderMatcher) {
method: 'GET',
url: '/?code=my-code&state=' + state,
cookies: {
'oauth2-redirect-state': state
'oauth2-redirect-state': state,
'oauth2-code-verifier': pkce ? 'myverifier' : undefined
}
}, function (err, responseEnd) {
t.error(err)
Expand Down Expand Up @@ -311,6 +317,84 @@ t.test('fastify-oauth2', t => {
makeRequests(t, fastify, userAgent => userAgent === undefined)
})

t.test('pkce.plain', t => {
const fastify = createFastify({ logger: { level: 'silent' } })

fastify.register(fastifyOauth2, {
name: 'githubOAuth2',
credentials: {
client: {
id: 'my-client-id',
secret: 'my-secret'
},
auth: fastifyOauth2.GITHUB_CONFIGURATION
},
startRedirectPath: '/login/github',
callbackUri: 'http://localhost:3000/callback',
scope: ['notifications'],
pkce: 'plain'
})

fastify.get('/', function (request, reply) {
return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply)
.then(result => {
// attempts to refresh the token
return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token)
})
.then(token => {
return {
access_token: token.token.access_token,
refresh_token: token.token.refresh_token,
expires_in: token.token.expires_in,
token_type: token.token.token_type
}
})
})

t.teardown(fastify.close.bind(fastify))

makeRequests(t, fastify, undefined, 'plain')
})

t.test('pkce.S256', t => {
const fastify = createFastify({ logger: { level: 'silent' } })

fastify.register(fastifyOauth2, {
name: 'githubOAuth2',
credentials: {
client: {
id: 'my-client-id',
secret: 'my-secret'
},
auth: fastifyOauth2.GITHUB_CONFIGURATION
},
startRedirectPath: '/login/github',
callbackUri: 'http://localhost:3000/callback',
scope: ['notifications'],
pkce: 'S256'
})

fastify.get('/', function (request, reply) {
return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply)
.then(result => {
// attempts to refresh the token
return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token)
})
.then(token => {
return {
access_token: token.token.access_token,
refresh_token: token.token.refresh_token,
expires_in: token.token.expires_in,
token_type: token.token.token_type
}
})
})

t.teardown(fastify.close.bind(fastify))

makeRequests(t, fastify, undefined, 'S256')
})

t.end()
})

Expand Down Expand Up @@ -765,6 +849,28 @@ t.test('options.userAgent should be a string', t => {
})
})

t.test('options.pkce', t => {
t.plan(1)

const fastify = createFastify({ logger: { level: 'silent' } })

fastify.register(fastifyOauth2, {
name: 'the-name',
credentials: {
client: {
id: 'my-client-id',
secret: 'my-secret'
},
auth: fastifyOauth2.GITHUB_CONFIGURATION
},
callbackUri: '/callback',
pkce: {}
})
.ready(err => {
t.strictSame(err.message, 'options.pkce should be one of "plain" | "S256" when used')
})
})

t.test('options.schema', t => {
const fastify = createFastify({ logger: { level: 'silent' }, exposeHeadRoutes: false })

Expand Down
Loading

0 comments on commit 3203054

Please sign in to comment.