Skip to content

Commit

Permalink
Spec compliant (#17)
Browse files Browse the repository at this point in the history
* Make the client spec compliant

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* tests passing

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* made form-urlencoded as default

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* Renamed to oidc-interceptor

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* added support for scope, resource and audience

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* code review

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* status codes

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* require idpTokenUrl and clientId

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* todos

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* fixup

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* Update tests/helper.js

Co-authored-by: Aranđel Šarenac <11753867+big-kahuna-burger@users.noreply.github.com>

* Update tests/oidc-provider.test.js

Co-authored-by: Aranđel Šarenac <11753867+big-kahuna-burger@users.noreply.github.com>

* fixup

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* fixup

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* handle token_type

Signed-off-by: Matteo Collina <hello@matteocollina.com>

---------

Signed-off-by: Matteo Collina <hello@matteocollina.com>
Co-authored-by: Aranđel Šarenac <11753867+big-kahuna-burger@users.noreply.github.com>
  • Loading branch information
mcollina and big-kahuna-burger authored Feb 19, 2024
1 parent c2e62de commit f695606
Show file tree
Hide file tree
Showing 11 changed files with 776 additions and 101 deletions.
50 changes: 40 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,61 @@
# undici-oauth-interceptor
# undici-oidc-interceptor

[![NPM version](https://img.shields.io/npm/v/undici-oauth-interceptor.svg?style=flat)](https://www.npmjs.com/package/undici-oauth-interceptor)

Manages an access token and automatically sets the `Authorization` header on any
[![NPM version](https://img.shields.io/npm/v/undici-oidc-interceptor.svg?style=flat)](https://www.npmjs.com/package/undici-oidc-interceptor)

`undici-oidc-interceptor` manages an [OIDC](https://openid.net/specs/openid-connect-core-1_0.html) access token and transparently sets the `Authorization` header on any
request that is going to a limited set of domains.

The token is automatically renewed after it expires. It supports both a `refresh_token`
and `client_credentials` grants.

## Install

```bash
npm i undici undici-oauth-interceptor
npm i undici undici-oidc-interceptor
```

## Usage with client credentials

```
const { Agent } = require('undici')
const { createOAuthIntercpetor } = require('undici-oidc-interceptor')
const dispatcher = new Agent({
intercpetors: {
Pool: [createOAuthIntercpetor({
// The paramerts for the cliend_credentials grant of OIDC
clientId: 'FILLME',
clientSecret: 'FILLME',
idpTokenUrl: 'https://your-idp.com/token',
// Set an array of status codes that the interceptor should refresh and
// retry the request on
retryOnStatusCodes: [401],
// The origins that this interceptor will add the `Authorization` header
// automatically
origins: ['FILLME']
// OPTIONAL: an initial access token
accessToken: ''
})]
}
})
```

## Usage
## Usage with refresh token

```javascript
const { Agent } = require('undici')
const { createOAuthIntercpetor } = require('undici-oauth-interceptor')
const { createOAuthIntercpetor } = require('undici-oidc-interceptor')
const dispatcher = new Agent({
intercpetors: {
Pool: [createOAuthIntercpetor({
// Provide a refresh token so the interceptor can manage the access token
// The refresh token must include an issuer (`iss`)
refreshToken: '',
idpTokenUrl: 'https://your-idp.com/token',
clientId: 'FILLME',

// Set an array of status codes that the interceptor should refresh and
// retry the request on
Expand All @@ -33,10 +67,6 @@ const dispatcher = new Agent({

// OPTIONAL: an initial access token
accessToken: ''

// OPTIONAL: clientId that matches refresh token
// Default: the `sub` claim in the refresh token
clientId: null
})]
}
})
Expand Down
77 changes: 65 additions & 12 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,78 @@

const { request } = require('undici')

async function refreshAccessToken ({ refreshEndpoint, refreshToken, clientId }) {
const { statusCode, body } = await request(`${refreshEndpoint}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
async function refreshAccessToken ({ idpTokenUrl, refreshToken, clientId, clientSecret, contentType, scope, resource, audience }) {
let objToSend = null

if (refreshToken) {
objToSend = {
refresh_token: refreshToken,
grant_type: 'refresh_token',
client_id: clientId
})
}
} else {
objToSend = {
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret
}
}

if (scope) objToSend.scope = scope

// Audience is Auth0 specific
if (audience) objToSend.audience = audience

let bodyToSend

if (contentType === 'json') {
// TODO(mcollina): remove JSON support as it's not spec compliant
contentType = 'application/json'
objToSend.resource = resource
bodyToSend = JSON.stringify(objToSend)
} else {
contentType = 'application/x-www-form-urlencoded'
const params = new URLSearchParams()
for (const [key, value] of Object.entries(objToSend)) {
params.set(key, value)
}
if (resource) {
if (typeof resource !== 'string' && resource[Symbol.iterator]) {
for (const r of resource) {
params.append('resource', r)
}
} else if (resource) {
params.set('resource', resource)
}
}
bodyToSend = params.toString()
}

const { statusCode, body } = await request(idpTokenUrl, {
method: 'POST',
headers: {
'Accept': 'application/json; charset=utf-8',
'Content-Type': contentType
},
body: bodyToSend
})

if (statusCode > 299) {
const { message } = await body.json()
throw new Error(`Failed to refresh access token - ${message}`)
if (statusCode !== 200) {
const parsed = await body.json()
throw new Error(`Failed to refresh access token - status code ${statusCode} - ${JSON.stringify(parsed)}`)
}

const { access_token: accessToken, token_type: tokenType } = await body.json()

if (!accessToken) {
throw new Error('Failed to refresh access token - no access_token in response')
}

// slight leeway on the spec, let's imply that token_type is bearer by default
if (tokenType && tokenType.toLowerCase() !== 'bearer') {
throw new Error(`Failed to refresh access token - unexpected token_type ${token_type}`)
}

const { access_token: accessToken } = await body.json()
return accessToken
}

Expand Down
52 changes: 31 additions & 21 deletions oauth-interceptor.js → oidc-interceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,45 @@ function getTokenState (token) {
return TOKEN_STATE.VALID
}

function createOAuthInterceptor (options) {
const { refreshToken, clientId } = options
function createOidcInterceptor (options) {
const { refreshToken, clientSecret, contentType } = options
let {
accessToken ,
accessToken,
retryOnStatusCodes,
origins
idpTokenUrl,
origins,
clientId,
scope,
resource,
audience
} = options

retryOnStatusCodes = retryOnStatusCodes || [401]
origins = origins || []

if (!refreshToken) {
throw new Error('refreshToken is required')
// TODO: if there is a refresh_token, we might not need the idpTokenUrl and use the standard
// discovery mechanism. See
// https://github.com/panva/oauth4webapi/blob/8173ba2944ede8beff11e59019940bbd6440ea96/src/index.ts#L1054-L1093
if (!idpTokenUrl) {
throw new Error('No idpTokenUrl provided')
}

const decoded = decode(refreshToken)
const { iss, sub } = decoded
if (!iss) throw new Error('refreshToken is invalid: iss is required')
if (!sub && !clientId) throw new Error('No clientId provided')

const refreshHost = iss
const client = clientId || sub
if (!clientId) throw new Error('No clientId provided')

let _requestingRefresh
function callRefreshToken (refreshEndpoint, refreshToken, clientId) {
function callRefreshToken () {
if (_requestingRefresh) return _requestingRefresh

_requestingRefresh = refreshAccessToken({ refreshEndpoint, refreshToken, clientId })
.finally(() => _requestingRefresh = null)
_requestingRefresh = refreshAccessToken({
idpTokenUrl,
refreshToken,
clientId,
clientSecret,
contentType,
scope,
resource,
audience
}).finally(() => _requestingRefresh = null)

return _requestingRefresh
}
Expand All @@ -65,7 +75,7 @@ function createOAuthInterceptor (options) {
}

if (opts.oauthRetry) {
return callRefreshToken(refreshHost, refreshToken, client)
return callRefreshToken()
.catch(err => {
handler.onError(err)
})
Expand Down Expand Up @@ -113,13 +123,13 @@ function createOAuthInterceptor (options) {

switch (getTokenState(accessToken)) {
case TOKEN_STATE.EXPIRED:
return callRefreshToken(refreshHost, refreshToken, client)
return callRefreshToken()
.then(saveTokenAndRetry)
.catch(err => {
handler.onError(err)
})
case TOKEN_STATE.NEAR_EXPIRATION:
callRefreshToken(refreshHost, refreshToken, client)
callRefreshToken()
.then(newAccessToken => {
accessToken = newAccessToken
dispatcher.emit('oauth:token-refreshed', newAccessToken)
Expand All @@ -132,5 +142,5 @@ function createOAuthInterceptor (options) {
}
}

module.exports = createOAuthInterceptor
module.exports.createOAuthInterceptor = createOAuthInterceptor
module.exports = createOidcInterceptor
module.exports.createOidcInterceptor = createOidcInterceptor
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "undici-oauth-interceptor",
"name": "undici-oidc-interceptor",
"version": "0.4.2",
"description": "Automatically manage OAuth 2.0 access tokens for Undici requests",
"main": "oauth-interceptor.js",
"description": "Automatically manage OIDC access tokens for Undici requests",
"main": "oidc-interceptor.js",
"scripts": {
"lint": "standard",
"test": "borp --coverage"
Expand All @@ -14,8 +14,13 @@
"undici": "^6.0.0"
},
"devDependencies": {
"@matteo.collina/tspl": "^0.1.1",
"borp": "^0.9.1",
"fast-querystring": "^1.1.2",
"fastify": "^4.24.3",
"get-jwks": "^9.0.1",
"jwks": "^1.0.0",
"oidc-provider": "^8.4.5",
"standard": "^17.1.0"
}
}
Loading

0 comments on commit f695606

Please sign in to comment.