Skip to content

Commit 81b6362

Browse files
Implements userinfo decorator to be used with discovery option (#243)
1 parent 6f9616d commit 81b6362

File tree

6 files changed

+741
-23
lines changed

6 files changed

+741
-23
lines changed

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ Assuming we have registered multiple OAuth providers like this:
319319

320320
## Utilities
321321

322-
This fastify plugin adds 5 utility decorators to your fastify instance using the same **namespace**:
322+
This fastify plugin adds 6 utility decorators to your fastify instance using the same **namespace**:
323323

324324
- `getAccessTokenFromAuthorizationCodeFlow(request, callback)`: A function that uses the Authorization code flow to fetch an OAuth2 token using the data in the last request of the flow. If the callback is not passed it will return a promise. The callback call or promise resolution returns an [AccessToken](https://github.com/lelylan/simple-oauth2/blob/master/API.md#accesstoken) object, which has an `AccessToken.token` property with the following keys:
325325
- `access_token`
@@ -363,6 +363,44 @@ fastify.googleOAuth2.revokeAllToken(currentAccessToken, undefined, (err) => {
363363
// Handle the reply here
364364
});
365365
```
366+
367+
- `userinfo(tokenOrTokenSet)`: A function to retrieve userinfo data from Authorization Provider. Both token (as object) or `access_token` string value can be passed.
368+
369+
Important note:
370+
Userinfo will only work when `discovery` option is used and such endpoint is advertised by identity provider.
371+
372+
For a statically configured plugin, you need to make a HTTP call yourself.
373+
374+
See more on OIDC standard definition for [Userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo)
375+
376+
See more on `userinfo_endpoint` property in [OIDC Discovery Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata) standard definition.
377+
378+
```js
379+
fastify.googleOAuth2.userinfo(currentAccessToken, (err, userinfo) => {
380+
// do something with userinfo
381+
});
382+
// with custom params
383+
fastify.googleOAuth2.userinfo(currentAccessToken, { method: 'GET', params: { /* add your custom key value pairs here to be appended to request */ } }, (err, userinfo) => {
384+
// do something with userinfo
385+
});
386+
387+
// or promise version
388+
const userinfo = await fastify.googleOAuth2.userinfo(currentAccessToken);
389+
// use custom params
390+
const userinfo = await fastify.googleOAuth2.userinfo(currentAccessToken, { method: 'GET', params: { /* ... */ } });
391+
```
392+
393+
There are variants with callback and promises.
394+
Custom parameters can be passed as option.
395+
See [Types](./types/index.d.ts) and usage patterns [in examples](./examples/userinfo.js).
396+
397+
Note:
398+
399+
We support HTTP `GET` and `POST` requests to userinfo endpoint sending access token using `Bearer` schema in headers.
400+
You can do this by setting (`via: "header"` parameter), but it's not mandatory since it's a default value.
401+
402+
We also support `POST` by sending `access_token` in a request body. You can do this by explicitly providing `via: "body"` parameter.
403+
366404
E.g. For `name: 'customOauth2'`, the helpers `getAccessTokenFromAuthorizationCodeFlow` and `getNewAccessTokenUsingRefreshToken` will become accessible like this:
367405

368406
- `fastify.oauth2CustomOauth2.getAccessTokenFromAuthorizationCodeFlow`

examples/userinfo.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use strict'
2+
3+
const fastify = require('fastify')({ logger: { level: 'trace' } })
4+
5+
const cookieOpts = {
6+
path: '/',
7+
secure: true,
8+
sameSite: 'lax',
9+
httpOnly: true
10+
}
11+
12+
// const oauthPlugin = require('fastify-oauth2')
13+
const oauthPlugin = require('..')
14+
15+
fastify.register(require('@fastify/cookie'), {
16+
secret: ['my-secret'],
17+
parseOptions: cookieOpts
18+
})
19+
20+
fastify.register(oauthPlugin, {
21+
name: 'googleOAuth2',
22+
// when provided, this userAgent will also be used at discovery endpoint
23+
// to fully omit for whatever reason, set it to false
24+
userAgent: 'my custom app (v1.0.0)',
25+
scope: ['openid', 'profile', 'email'],
26+
credentials: {
27+
client: {
28+
id: process.env.CLIENT_ID,
29+
secret: process.env.CLIENT_SECRET
30+
}
31+
},
32+
startRedirectPath: '/login/google',
33+
callbackUri: 'http://localhost:3000/interaction/callback/google',
34+
cookie: cookieOpts,
35+
discovery: {
36+
issuer: 'https://accounts.google.com'
37+
}
38+
})
39+
40+
// using async/await (promises API) ->
41+
// 1. simple one with async
42+
fastify.get('/interaction/callback/google', async function (request, reply) {
43+
const tokenResponse = await this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply)
44+
const userinfo = await this.googleOAuth2.userinfo(tokenResponse.token /* or tokenResponse.token.access_token */)
45+
return userinfo
46+
})
47+
48+
// 2. custom params one with async
49+
// fastify.get('/interaction/callback/google', { method: 'GET', params: { /* custom parameters to be added */ } }, async function (request, reply) {
50+
// const tokenResponse = await this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply)
51+
// const userinfo = await this.googleOAuth2.userinfo(tokenResponse.token /* or tokenResponse.token.access_token */)
52+
// return userinfo
53+
// })
54+
55+
// OR with a callback API
56+
57+
// 3. simple one with callback
58+
// fastify.get('/interaction/callback/google', function (request, reply) {
59+
// const userInfoCallback = (err, userinfo) => {
60+
// if (err) {
61+
// reply.send(err)
62+
// return
63+
// }
64+
// reply.send(userinfo)
65+
// }
66+
67+
// const accessTokenCallback = (err, result) => {
68+
// if (err) {
69+
// reply.send(err)
70+
// return
71+
// }
72+
// this.googleOAuth2.userinfo(result.token, userInfoCallback)
73+
// }
74+
75+
// this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, accessTokenCallback)
76+
// })
77+
78+
// 4. custom params one with with callback
79+
// fastify.get('/interaction/callback/google', { method: 'GET', params: { /** custom parameters to be added */ } }, function (request, reply) {
80+
// const userInfoCallback = (err, userinfo) => {
81+
// if (err) {
82+
// reply.send(err)
83+
// return
84+
// }
85+
// reply.send(userinfo)
86+
// }
87+
88+
// const accessTokenCallback = (err, result) => {
89+
// if (err) {
90+
// reply.send(err)
91+
// return
92+
// }
93+
// this.googleOAuth2.userinfo(result.token, userInfoCallback)
94+
// }
95+
96+
// this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, accessTokenCallback)
97+
// })
98+
99+
fastify.listen({ port: 3000 })
100+
fastify.log.info('go to http://localhost:3000/login/google')

index.js

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ function fastifyOauth2 (fastify, options, next) {
104104
? undefined
105105
: (options.userAgent || USER_AGENT)
106106

107-
const configure = (configured) => {
107+
const configure = (configured, fetchedMetadata) => {
108108
const {
109109
name,
110110
callbackUri,
@@ -299,6 +299,51 @@ function fastifyOauth2 (fastify, options, next) {
299299
reply.clearCookie(VERIFIER_COOKIE_NAME, cookieOpts)
300300
}
301301

302+
const pUserInfo = promisify(userInfoCallbacked)
303+
304+
function userinfo (tokenSetOrToken, options, callback) {
305+
const _callback = typeof options === 'function' ? options : callback
306+
if (!_callback) {
307+
return pUserInfo(tokenSetOrToken, options)
308+
}
309+
return userInfoCallbacked(tokenSetOrToken, options, _callback)
310+
}
311+
312+
function userInfoCallbacked (tokenSetOrToken, { method = 'GET', via = 'header', params = {} } = {}, callback) {
313+
if (!configured.discovery) {
314+
callback(new Error('userinfo can not be used without discovery'))
315+
return
316+
}
317+
const _method = method.toUpperCase()
318+
if (!['GET', 'POST'].includes(_method)) {
319+
callback(new Error('userinfo methods supported are only GET and POST'))
320+
return
321+
}
322+
323+
if (method === 'GET' && via === 'body') {
324+
callback(new Error('body is supported only with POST'))
325+
return
326+
}
327+
328+
let token
329+
if (typeof tokenSetOrToken !== 'object' && typeof tokenSetOrToken !== 'string') {
330+
callback(new Error('you should provide token object containing access_token or access_token as string directly'))
331+
return
332+
}
333+
334+
if (typeof tokenSetOrToken === 'object') {
335+
if (typeof tokenSetOrToken.access_token !== 'string') {
336+
callback(new Error('access_token should be string'))
337+
return
338+
}
339+
token = tokenSetOrToken.access_token
340+
} else {
341+
token = tokenSetOrToken
342+
}
343+
344+
fetchUserInfo(fetchedMetadata.userinfo_endpoint, token, { method: _method, params, via }, callback)
345+
}
346+
302347
const oauth2 = new AuthorizationCode(configured.credentials)
303348

304349
if (startRedirectPath) {
@@ -311,7 +356,8 @@ function fastifyOauth2 (fastify, options, next) {
311356
getNewAccessTokenUsingRefreshToken,
312357
generateAuthorizationUri,
313358
revokeToken,
314-
revokeAllToken
359+
revokeAllToken,
360+
userinfo
315361
}
316362

317363
try {
@@ -343,7 +389,7 @@ function fastifyOauth2 (fastify, options, next) {
343389
// otherwise select optimal pkce method for them,
344390
discoveredOptions.pkce = selectPkceFromMetadata(fetchedMetadata)
345391
}
346-
configure(discoveredOptions)
392+
configure(discoveredOptions, fetchedMetadata)
347393
next()
348394
})
349395
} else {
@@ -383,6 +429,73 @@ function fastifyOauth2 (fastify, options, next) {
383429
})
384430
}
385431
}
432+
433+
function fetchUserInfo (userinfoEndpoint, token, { method, via, params }, cb) {
434+
const httpOpts = {
435+
method,
436+
headers: {
437+
...options.credentials.http?.headers,
438+
'User-Agent': userAgent,
439+
Authorization: `Bearer ${token}`
440+
}
441+
}
442+
443+
if (omitUserAgent) {
444+
delete httpOpts.headers['User-Agent']
445+
}
446+
447+
const infoUrl = new URL(userinfoEndpoint)
448+
449+
let body
450+
451+
if (method === 'GET') {
452+
Object.entries(params).forEach(([k, v]) => {
453+
infoUrl.searchParams.append(k, v)
454+
})
455+
} else {
456+
httpOpts.headers['Content-Type'] = 'application/x-www-form-urlencoded'
457+
body = new URLSearchParams()
458+
if (via === 'body') {
459+
delete httpOpts.headers.Authorization
460+
body.append('access_token', token)
461+
}
462+
Object.entries(params).forEach(([k, v]) => {
463+
body.append(k, v)
464+
})
465+
}
466+
467+
const aClient = (userinfoEndpoint.startsWith('https://') ? https : http)
468+
469+
if (method === 'GET') {
470+
aClient.get(infoUrl, httpOpts, onUserinfoResponse)
471+
.on('error', errHandler)
472+
return
473+
}
474+
475+
const req = aClient.request(infoUrl, httpOpts, onUserinfoResponse)
476+
.on('error', errHandler)
477+
478+
req.write(body.toString())
479+
req.end()
480+
481+
function onUserinfoResponse (res) {
482+
let rawData = ''
483+
res.on('data', (chunk) => { rawData = chunk })
484+
res.on('end', () => {
485+
try {
486+
cb(null, JSON.parse(rawData)) // should always be JSON since we don't do jwt auth response
487+
} catch (err) {
488+
cb(err)
489+
}
490+
})
491+
}
492+
493+
function errHandler (e) {
494+
const err = new Error('Problem calling userinfo endpoint. See innerError for details.')
495+
err.innerError = e
496+
cb(err)
497+
}
498+
}
386499
}
387500

388501
function getDiscoveryUri (issuer) {

0 commit comments

Comments
 (0)