Skip to content

Commit a006b17

Browse files
feat: implement PKCE extension for Authorization Code Grant (RFC 7636) (#240)
* feat: implement PKCE extension for Authorization Code Grant (RFC 7636) See protocol details here: https://www.rfc-editor.org/rfc/rfc7636.html * fix: removes duplicated toString('base64url') * feat: address PR review and document challenge methods * fix: swap it in types and args check
1 parent f7db10d commit a006b17

File tree

6 files changed

+314
-21
lines changed

6 files changed

+314
-21
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ fastify.register(oauthPlugin, {
206206
// custom query param that will be passed to callbackUri
207207
access_type: 'offline', // will tell Google to send a refreshToken too
208208
},
209+
pkce: 'S256'
210+
// check if your provider supports PKCE,
211+
// in case they do,
212+
// use of this parameter is highly encouraged
213+
// in order to prevent authorization code interception attacks
209214
});
210215
```
211216

@@ -252,6 +257,14 @@ This fastify plugin adds 5 utility decorators to your fastify instance using the
252257
- `refresh_token` (optional, only if the `offline scope` was originally requested, as seen in the callbackUriParams example)
253258
- `token_type` (generally `'Bearer'`)
254259
- `expires_in` (number of seconds for the token to expire, e.g. `240000`)
260+
261+
- OR `getAccessTokenFromAuthorizationCodeFlow(request, reply, callback)` variant with 3 arguments, which should be used when PKCE extension is used.
262+
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).
263+
264+
*Important to note*: if your provider supports `S256` as code_challenge_method, always prefer that.
265+
Only use `plain` when your provider doesn't support `S256`.
266+
267+
255268
- `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`:
256269
```js
257270
fastify.googleOAuth2.getNewAccessTokenUsingRefreshToken(currentAccessToken, (err, newAccessToken) => {

examples/google-with-pkce.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict'
2+
3+
const fastify = require('fastify')({ logger: { level: 'trace' } })
4+
const sget = require('simple-get')
5+
6+
const cookieOpts = {
7+
// domain: 'localhost',
8+
path: '/',
9+
secure: true,
10+
sameSite: 'lax',
11+
httpOnly: true
12+
}
13+
14+
// const oauthPlugin = require('fastify-oauth2')
15+
fastify.register(require('@fastify/cookie'), {
16+
secret: ['my-secret'],
17+
parseOptions: cookieOpts
18+
})
19+
20+
const oauthPlugin = require('..')
21+
fastify.register(oauthPlugin, {
22+
name: 'googleOAuth2',
23+
scope: ['openid', 'profile', 'email'],
24+
credentials: {
25+
client: {
26+
id: process.env.CLIENT_ID,
27+
secret: process.env.CLIENT_SECRET
28+
},
29+
auth: oauthPlugin.GOOGLE_CONFIGURATION
30+
},
31+
startRedirectPath: '/login/google',
32+
callbackUri: 'http://localhost:3000/interaction/callback/google',
33+
cookie: cookieOpts,
34+
pkce: 'S256'
35+
/* use S256:
36+
37+
Most modern providers (authorization servers) that are up to date with standards,
38+
will support S256 and also announce that in discovery endpoint (.well-known/openid-configuration):
39+
...
40+
"code_challenge_methods_supported": [
41+
"S256",
42+
"plain"
43+
]
44+
...
45+
46+
"plain" is also supported in this library but it's use is discouraged.
47+
Only do it in case that you use some legacy provider (authorization server),
48+
and you see provider's .well-known/openid-configuration
49+
endpoint has only that single challenge method:
50+
...
51+
"code_challenge_methods_supported": [
52+
"plain"
53+
]
54+
*/
55+
})
56+
57+
fastify.get('/interaction/callback/google', function (request, reply) {
58+
// Note that in this example a "reply" is also passed, it's so that code verifier cookie can be cleaned before
59+
// token is requested from token endpoint
60+
this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, (err, result) => {
61+
if (err) {
62+
reply.send(err)
63+
return
64+
}
65+
66+
sget.concat({
67+
url: 'https://www.googleapis.com/oauth2/v2/userinfo',
68+
method: 'GET',
69+
headers: {
70+
Authorization: 'Bearer ' + result.token.access_token
71+
},
72+
json: true
73+
}, function (err, res, data) {
74+
if (err) {
75+
reply.send(err)
76+
return
77+
}
78+
reply.send(data)
79+
})
80+
})
81+
})
82+
83+
fastify.listen({ port: 3000 })
84+
fastify.log.info('go to http://localhost:3000/login/google')

index.js

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const { randomBytes } = require('node:crypto')
3+
const { randomBytes, createHash } = require('node:crypto')
44

55
const fp = require('fastify-plugin')
66
const { AuthorizationCode } = require('simple-oauth2')
@@ -9,9 +9,15 @@ const kGenerateCallbackUriParams = Symbol.for('fastify-oauth2.generate-callback-
99
const { promisify, callbackify } = require('node:util')
1010

1111
const USER_AGENT = 'fastify-oauth2'
12+
const VERIFIER_COOKIE_NAME = 'oauth2-code-verifier'
13+
const PKCE_METHODS = ['S256', 'plain']
14+
15+
const random = (bytes = 32) => randomBytes(bytes).toString('base64url')
16+
const codeVerifier = random
17+
const codeChallenge = verifier => createHash('sha256').update(verifier).digest('base64url')
1218

1319
function defaultGenerateStateFunction () {
14-
return randomBytes(16).toString('base64url')
20+
return random(16)
1521
}
1622

1723
function defaultCheckStateFunction (request, callback) {
@@ -74,7 +80,9 @@ function fastifyOauth2 (fastify, options, next) {
7480
if (options.userAgent && typeof options.userAgent !== 'string') {
7581
return next(new Error('options.userAgent should be a string'))
7682
}
77-
83+
if (options.pkce && (typeof options.pkce !== 'string' || !PKCE_METHODS.includes(options.pkce))) {
84+
return next(new Error('options.pkce should be one of "S256" | "plain" when used'))
85+
}
7886
if (!fastify.hasReplyDecorator('cookie')) {
7987
fastify.register(require('@fastify/cookie'))
8088
}
@@ -89,7 +97,8 @@ function fastifyOauth2 (fastify, options, next) {
8997
checkStateFunction = defaultCheckStateFunction,
9098
startRedirectPath,
9199
tags = [],
92-
schema = { tags }
100+
schema = { tags },
101+
pkce
93102
} = options
94103

95104
const userAgent = options.userAgent === false
@@ -113,11 +122,23 @@ function fastifyOauth2 (fastify, options, next) {
113122

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

125+
// when PKCE extension is used
126+
let pkceParams = {}
127+
if (pkce) {
128+
const verifier = codeVerifier()
129+
const challenge = pkce === 'S256' ? codeChallenge(verifier) : verifier
130+
pkceParams = {
131+
code_challenge: challenge,
132+
code_challenge_method: pkce
133+
}
134+
reply.setCookie(VERIFIER_COOKIE_NAME, verifier, cookieOpts)
135+
}
136+
116137
const urlOptions = Object.assign({}, generateCallbackUriParams(callbackUriParams, request, scope, state), {
117138
redirect_uri: callbackUri,
118139
scope,
119140
state
120-
})
141+
}, pkceParams)
121142

122143
return oauth2.authorizeURL(urlOptions)
123144
}
@@ -128,34 +149,44 @@ function fastifyOauth2 (fastify, options, next) {
128149
reply.redirect(authorizationUri)
129150
}
130151

131-
const cbk = function (o, code, callback) {
152+
const cbk = function (o, code, pkceParams, callback) {
132153
const body = Object.assign({}, tokenRequestParams, {
133154
code,
134155
redirect_uri: callbackUri
135-
})
156+
}, pkceParams)
136157

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

140-
function getAccessTokenFromAuthorizationCodeFlowCallbacked (request, callback) {
161+
function getAccessTokenFromAuthorizationCodeFlowCallbacked (request, reply, callback) {
141162
const code = request.query.code
163+
const pkceParams = pkce ? { code_verifier: request.cookies['oauth2-code-verifier'] } : {}
164+
165+
const _callback = typeof reply === 'function' ? reply : callback
166+
167+
if (reply && typeof reply !== 'function') {
168+
// cleanup a cookie if plugin user uses (req, res, cb) signature variant of getAccessToken fn
169+
clearCodeVerifierCookie(reply)
170+
}
142171

143172
checkStateFunction(request, function (err) {
144173
if (err) {
145174
callback(err)
146175
return
147176
}
148-
cbk(fastify[name], code, callback)
177+
cbk(fastify[name], code, pkceParams, _callback)
149178
})
150179
}
151180

152181
const getAccessTokenFromAuthorizationCodeFlowPromisified = promisify(getAccessTokenFromAuthorizationCodeFlowCallbacked)
153182

154-
function getAccessTokenFromAuthorizationCodeFlow (request, callback) {
155-
if (!callback) {
156-
return getAccessTokenFromAuthorizationCodeFlowPromisified(request)
183+
function getAccessTokenFromAuthorizationCodeFlow (request, reply, callback) {
184+
const _callback = typeof reply === 'function' ? reply : callback
185+
186+
if (!_callback) {
187+
return getAccessTokenFromAuthorizationCodeFlowPromisified(request, reply)
157188
}
158-
getAccessTokenFromAuthorizationCodeFlowCallbacked(request, callback)
189+
getAccessTokenFromAuthorizationCodeFlowCallbacked(request, reply, _callback)
159190
}
160191

161192
function getNewAccessTokenUsingRefreshTokenCallbacked (refreshToken, params, callback) {
@@ -200,6 +231,10 @@ function fastifyOauth2 (fastify, options, next) {
200231
revokeAllTokenCallbacked(token, params, callback)
201232
}
202233

234+
function clearCodeVerifierCookie (reply) {
235+
reply.clearCookie(VERIFIER_COOKIE_NAME, cookieOpts)
236+
}
237+
203238
const oauth2 = new AuthorizationCode(credentials)
204239

205240
if (startRedirectPath) {

0 commit comments

Comments
 (0)