Skip to content

Commit

Permalink
Implements userinfo decorator to be used with discovery option (#243
Browse files Browse the repository at this point in the history
)
  • Loading branch information
big-kahuna-burger authored Nov 30, 2023
1 parent 6f9616d commit 81b6362
Show file tree
Hide file tree
Showing 6 changed files with 741 additions and 23 deletions.
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ Assuming we have registered multiple OAuth providers like this:

## Utilities

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

- `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:
- `access_token`
Expand Down Expand Up @@ -363,6 +363,44 @@ fastify.googleOAuth2.revokeAllToken(currentAccessToken, undefined, (err) => {
// Handle the reply here
});
```

- `userinfo(tokenOrTokenSet)`: A function to retrieve userinfo data from Authorization Provider. Both token (as object) or `access_token` string value can be passed.

Important note:
Userinfo will only work when `discovery` option is used and such endpoint is advertised by identity provider.

For a statically configured plugin, you need to make a HTTP call yourself.

See more on OIDC standard definition for [Userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo)

See more on `userinfo_endpoint` property in [OIDC Discovery Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata) standard definition.

```js
fastify.googleOAuth2.userinfo(currentAccessToken, (err, userinfo) => {
// do something with userinfo
});
// with custom params
fastify.googleOAuth2.userinfo(currentAccessToken, { method: 'GET', params: { /* add your custom key value pairs here to be appended to request */ } }, (err, userinfo) => {
// do something with userinfo
});

// or promise version
const userinfo = await fastify.googleOAuth2.userinfo(currentAccessToken);
// use custom params
const userinfo = await fastify.googleOAuth2.userinfo(currentAccessToken, { method: 'GET', params: { /* ... */ } });
```

There are variants with callback and promises.
Custom parameters can be passed as option.
See [Types](./types/index.d.ts) and usage patterns [in examples](./examples/userinfo.js).

Note:

We support HTTP `GET` and `POST` requests to userinfo endpoint sending access token using `Bearer` schema in headers.
You can do this by setting (`via: "header"` parameter), but it's not mandatory since it's a default value.

We also support `POST` by sending `access_token` in a request body. You can do this by explicitly providing `via: "body"` parameter.

E.g. For `name: 'customOauth2'`, the helpers `getAccessTokenFromAuthorizationCodeFlow` and `getNewAccessTokenUsingRefreshToken` will become accessible like this:

- `fastify.oauth2CustomOauth2.getAccessTokenFromAuthorizationCodeFlow`
Expand Down
100 changes: 100 additions & 0 deletions examples/userinfo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
'use strict'

const fastify = require('fastify')({ logger: { level: 'trace' } })

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

// const oauthPlugin = require('fastify-oauth2')
const oauthPlugin = require('..')

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

fastify.register(oauthPlugin, {
name: 'googleOAuth2',
// when provided, this userAgent will also be used at discovery endpoint
// to fully omit for whatever reason, set it to false
userAgent: 'my custom app (v1.0.0)',
scope: ['openid', 'profile', 'email'],
credentials: {
client: {
id: process.env.CLIENT_ID,
secret: process.env.CLIENT_SECRET
}
},
startRedirectPath: '/login/google',
callbackUri: 'http://localhost:3000/interaction/callback/google',
cookie: cookieOpts,
discovery: {
issuer: 'https://accounts.google.com'
}
})

// using async/await (promises API) ->
// 1. simple one with async
fastify.get('/interaction/callback/google', async function (request, reply) {
const tokenResponse = await this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply)
const userinfo = await this.googleOAuth2.userinfo(tokenResponse.token /* or tokenResponse.token.access_token */)
return userinfo
})

// 2. custom params one with async
// fastify.get('/interaction/callback/google', { method: 'GET', params: { /* custom parameters to be added */ } }, async function (request, reply) {
// const tokenResponse = await this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply)
// const userinfo = await this.googleOAuth2.userinfo(tokenResponse.token /* or tokenResponse.token.access_token */)
// return userinfo
// })

// OR with a callback API

// 3. simple one with callback
// fastify.get('/interaction/callback/google', function (request, reply) {
// const userInfoCallback = (err, userinfo) => {
// if (err) {
// reply.send(err)
// return
// }
// reply.send(userinfo)
// }

// const accessTokenCallback = (err, result) => {
// if (err) {
// reply.send(err)
// return
// }
// this.googleOAuth2.userinfo(result.token, userInfoCallback)
// }

// this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, accessTokenCallback)
// })

// 4. custom params one with with callback
// fastify.get('/interaction/callback/google', { method: 'GET', params: { /** custom parameters to be added */ } }, function (request, reply) {
// const userInfoCallback = (err, userinfo) => {
// if (err) {
// reply.send(err)
// return
// }
// reply.send(userinfo)
// }

// const accessTokenCallback = (err, result) => {
// if (err) {
// reply.send(err)
// return
// }
// this.googleOAuth2.userinfo(result.token, userInfoCallback)
// }

// this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, accessTokenCallback)
// })

fastify.listen({ port: 3000 })
fastify.log.info('go to http://localhost:3000/login/google')
119 changes: 116 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function fastifyOauth2 (fastify, options, next) {
? undefined
: (options.userAgent || USER_AGENT)

const configure = (configured) => {
const configure = (configured, fetchedMetadata) => {
const {
name,
callbackUri,
Expand Down Expand Up @@ -299,6 +299,51 @@ function fastifyOauth2 (fastify, options, next) {
reply.clearCookie(VERIFIER_COOKIE_NAME, cookieOpts)
}

const pUserInfo = promisify(userInfoCallbacked)

function userinfo (tokenSetOrToken, options, callback) {
const _callback = typeof options === 'function' ? options : callback
if (!_callback) {
return pUserInfo(tokenSetOrToken, options)
}
return userInfoCallbacked(tokenSetOrToken, options, _callback)
}

function userInfoCallbacked (tokenSetOrToken, { method = 'GET', via = 'header', params = {} } = {}, callback) {
if (!configured.discovery) {
callback(new Error('userinfo can not be used without discovery'))
return
}
const _method = method.toUpperCase()
if (!['GET', 'POST'].includes(_method)) {
callback(new Error('userinfo methods supported are only GET and POST'))
return
}

if (method === 'GET' && via === 'body') {
callback(new Error('body is supported only with POST'))
return
}

let token
if (typeof tokenSetOrToken !== 'object' && typeof tokenSetOrToken !== 'string') {
callback(new Error('you should provide token object containing access_token or access_token as string directly'))
return
}

if (typeof tokenSetOrToken === 'object') {
if (typeof tokenSetOrToken.access_token !== 'string') {
callback(new Error('access_token should be string'))
return
}
token = tokenSetOrToken.access_token
} else {
token = tokenSetOrToken
}

fetchUserInfo(fetchedMetadata.userinfo_endpoint, token, { method: _method, params, via }, callback)
}

const oauth2 = new AuthorizationCode(configured.credentials)

if (startRedirectPath) {
Expand All @@ -311,7 +356,8 @@ function fastifyOauth2 (fastify, options, next) {
getNewAccessTokenUsingRefreshToken,
generateAuthorizationUri,
revokeToken,
revokeAllToken
revokeAllToken,
userinfo
}

try {
Expand Down Expand Up @@ -343,7 +389,7 @@ function fastifyOauth2 (fastify, options, next) {
// otherwise select optimal pkce method for them,
discoveredOptions.pkce = selectPkceFromMetadata(fetchedMetadata)
}
configure(discoveredOptions)
configure(discoveredOptions, fetchedMetadata)
next()
})
} else {
Expand Down Expand Up @@ -383,6 +429,73 @@ function fastifyOauth2 (fastify, options, next) {
})
}
}

function fetchUserInfo (userinfoEndpoint, token, { method, via, params }, cb) {
const httpOpts = {
method,
headers: {
...options.credentials.http?.headers,
'User-Agent': userAgent,
Authorization: `Bearer ${token}`
}
}

if (omitUserAgent) {
delete httpOpts.headers['User-Agent']
}

const infoUrl = new URL(userinfoEndpoint)

let body

if (method === 'GET') {
Object.entries(params).forEach(([k, v]) => {
infoUrl.searchParams.append(k, v)
})
} else {
httpOpts.headers['Content-Type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams()
if (via === 'body') {
delete httpOpts.headers.Authorization
body.append('access_token', token)
}
Object.entries(params).forEach(([k, v]) => {
body.append(k, v)
})
}

const aClient = (userinfoEndpoint.startsWith('https://') ? https : http)

if (method === 'GET') {
aClient.get(infoUrl, httpOpts, onUserinfoResponse)
.on('error', errHandler)
return
}

const req = aClient.request(infoUrl, httpOpts, onUserinfoResponse)
.on('error', errHandler)

req.write(body.toString())
req.end()

function onUserinfoResponse (res) {
let rawData = ''
res.on('data', (chunk) => { rawData = chunk })
res.on('end', () => {
try {
cb(null, JSON.parse(rawData)) // should always be JSON since we don't do jwt auth response
} catch (err) {
cb(err)
}
})
}

function errHandler (e) {
const err = new Error('Problem calling userinfo endpoint. See innerError for details.')
err.innerError = e
cb(err)
}
}
}

function getDiscoveryUri (issuer) {
Expand Down
Loading

0 comments on commit 81b6362

Please sign in to comment.