newman-oauth-refresh 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +241 -2
- package/package.json +40 -4
- package/src/authorized-fetch.js +97 -0
- package/src/index.js +102 -0
- package/src/jwt.js +83 -0
- package/src/postman/prerequest.js +177 -0
- package/src/token-manager.js +231 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shivam Mahajan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,242 @@
|
|
|
1
|
-
#
|
|
1
|
+
# newman-oauth-refresh
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/gl1tchdust/newman-oauth-refresh/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/newman-oauth-refresh)
|
|
5
|
+
|
|
6
|
+
OAuth2 token refresh for [Newman](https://github.com/postmanlabs/newman) collection
|
|
7
|
+
runs. Proactive for JWTs, reactive on 401 for opaque tokens, and never writes a
|
|
8
|
+
token to disk.
|
|
9
|
+
|
|
10
|
+
## The problem
|
|
11
|
+
|
|
12
|
+
Postman's app refreshes OAuth2 tokens for you. Newman does not, so a collection
|
|
13
|
+
longer than the token's lifetime starts returning 401s partway through and
|
|
14
|
+
every request after that fails.
|
|
15
|
+
|
|
16
|
+
Two issues, and their states matter:
|
|
17
|
+
|
|
18
|
+
- [postmanlabs/postman-app-support#10112](https://github.com/postmanlabs/postman-app-support/issues/10112)
|
|
19
|
+
"Auto update/refresh of OAuth2 access tokens" — **closed as completed**,
|
|
20
|
+
March 2025. The app got it.
|
|
21
|
+
- [postmanlabs/newman#3219](https://github.com/postmanlabs/newman/issues/3219)
|
|
22
|
+
"Automatic Token Refresh Not Working in Newman CLI When OAuth 2.0 Access
|
|
23
|
+
Token Expires" — **still open.** The CLI did not.
|
|
24
|
+
|
|
25
|
+
So this is not a feature nobody got round to. It shipped in one product and
|
|
26
|
+
not the other, and Postman's documentation says so plainly: scheduled runs,
|
|
27
|
+
monitors, "the Postman CLI, and Newman don't support automatic token refresh"
|
|
28
|
+
([Postman docs, OAuth 2.0](https://learning.postman.com/docs/sending-requests/authorization/oauth-20/)).
|
|
29
|
+
|
|
30
|
+
That claim is checked, not quoted on trust. `scripts/premise.mjs` runs a
|
|
31
|
+
collection with Postman's own OAuth 2.0 auth block fully configured, refresh
|
|
32
|
+
token included, and expires the access token after the first request:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
newman, unassisted: req/first:200 req/second:401 req/third:401
|
|
36
|
+
token endpoint calls: 0
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Zero. Newman never attempted a refresh. CI runs that check on every push, and
|
|
40
|
+
**fails if Newman ever starts refreshing on its own** — at which point this
|
|
41
|
+
package should be retired rather than maintained.
|
|
42
|
+
|
|
43
|
+
## Three failure modes in the existing workarounds
|
|
44
|
+
|
|
45
|
+
Most published workarounds are a pre-request snippet that fetches a token
|
|
46
|
+
before every request. They mostly work, and they fail in three specific ways.
|
|
47
|
+
|
|
48
|
+
**1. The refresh call drops your custom headers.** Snippets build their token
|
|
49
|
+
request from scratch and send nothing but `Content-Type`. If your token
|
|
50
|
+
endpoint sits behind a gateway that requires a `User-Agent`, or your provider
|
|
51
|
+
wants an `X-Client-ID`, the refresh is rejected and the error you see is a
|
|
52
|
+
confusing 400 from an endpoint that worked yesterday. Headers you configure
|
|
53
|
+
here are passed through to the token call, and there is a test asserting they
|
|
54
|
+
arrive.
|
|
55
|
+
|
|
56
|
+
**2. They force HTTP Basic for client credentials.** RFC 6749 §2.3.1 permits
|
|
57
|
+
credentials in the request body, and a number of providers accept only that
|
|
58
|
+
form. A snippet hardcoded to `Authorization: Basic` cannot talk to them at all.
|
|
59
|
+
Body is the default here; Basic is one variable away.
|
|
60
|
+
|
|
61
|
+
**3. They cache the token to disk.** Usually by writing it into an environment
|
|
62
|
+
file so the next run can reuse it. That leaves a live bearer token on every CI
|
|
63
|
+
runner, in every container layer, and in anything that archives the workspace.
|
|
64
|
+
Nothing in this package reads or writes a token file, and a test monkeypatches
|
|
65
|
+
every `fs` write API to prove a full refresh cycle touches none of them.
|
|
66
|
+
|
|
67
|
+
A fourth, less often noticed: providers that **rotate** refresh tokens
|
|
68
|
+
invalidate the old one on use. Keeping the original breaks the *second*
|
|
69
|
+
refresh, an hour into a long run, which is a miserable thing to debug. Rotated
|
|
70
|
+
tokens are adopted automatically.
|
|
71
|
+
|
|
72
|
+
## 60-second quickstart
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install newman-oauth-refresh
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Running Newman as a library:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import newman from 'newman'
|
|
82
|
+
import { withOAuthRefresh } from 'newman-oauth-refresh'
|
|
83
|
+
|
|
84
|
+
newman.run({
|
|
85
|
+
collection: withOAuthRefresh(require('./collection.json')),
|
|
86
|
+
envVar: [
|
|
87
|
+
{ key: 'oauthTokenUrl', value: 'https://id.example.com/oauth2/token' },
|
|
88
|
+
{ key: 'oauthClientId', value: process.env.CLIENT_ID },
|
|
89
|
+
{ key: 'oauthClientSecret', value: process.env.CLIENT_SECRET },
|
|
90
|
+
{ key: 'oauthRefreshToken', value: process.env.REFRESH_TOKEN }
|
|
91
|
+
]
|
|
92
|
+
}, (err, summary) => { /* ... */ })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Point your requests at `Bearer {{oauthAccessToken}}` and that is the whole
|
|
96
|
+
integration.
|
|
97
|
+
|
|
98
|
+
Prefer the CLI? Paste the module into your collection's **Pre-request Script**
|
|
99
|
+
tab once:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
node -e "console.log(require('newman-oauth-refresh').preRequestScript())" > prerequest.js
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
then run `newman run collection.json` as usual.
|
|
106
|
+
|
|
107
|
+
## How it refreshes
|
|
108
|
+
|
|
109
|
+
Two paths, because tokens come in two shapes and neither path covers both.
|
|
110
|
+
|
|
111
|
+
**Proactive**, for JWTs. The token says when it expires, so decode `exp` and
|
|
112
|
+
refresh a fixed margin before. No request ever goes out carrying a token that
|
|
113
|
+
dies in flight, which is the thing that makes expiry bugs intermittent.
|
|
114
|
+
Signature verification is deliberately absent: we are the client that was
|
|
115
|
+
handed this token, not the resource server that has to trust it.
|
|
116
|
+
|
|
117
|
+
**Reactive**, for opaque tokens. Nothing in the token says anything, so the
|
|
118
|
+
401 is the only available signal. Take it, refresh, replay the request once,
|
|
119
|
+
carry on.
|
|
120
|
+
|
|
121
|
+
Reactive alone is not enough: it turns every expiry into a visible failed
|
|
122
|
+
request, and some gateways alert on 401 rates. Proactive alone is not enough
|
|
123
|
+
either: it cannot work for opaque tokens, and clock skew between you and the
|
|
124
|
+
issuer will eventually beat any margin you pick. Both, or there is a gap.
|
|
125
|
+
|
|
126
|
+
Exactly one retry, never a loop. A second 401 means the credential is wrong,
|
|
127
|
+
the scope is missing, or the resource genuinely is not yours, and retrying past
|
|
128
|
+
that turns an authorization bug into a flood against your token endpoint.
|
|
129
|
+
|
|
130
|
+
## What Newman actually allows
|
|
131
|
+
|
|
132
|
+
Four behaviours, each established by running real Newman rather than reading
|
|
133
|
+
about it. They shaped the design, and they are the reason this is a
|
|
134
|
+
pre-request module rather than an event hook.
|
|
135
|
+
|
|
136
|
+
| Behaviour | Result |
|
|
137
|
+
|---|---|
|
|
138
|
+
| Mutating headers in a `beforeRequest` listener | **works** |
|
|
139
|
+
| Newman awaiting an `async` event handler | **no** — the request goes out first |
|
|
140
|
+
| `pm.sendRequest` in a pre-request script being awaited | **yes** |
|
|
141
|
+
| `postman.setNextRequest` replaying the current request | **yes** |
|
|
142
|
+
|
|
143
|
+
The second row is the constraint everything else follows from. A
|
|
144
|
+
`beforeRequest` listener can stamp a header, but it cannot *wait* for a token,
|
|
145
|
+
so a Newman event hook can never refresh one. `pm.sendRequest` inside a
|
|
146
|
+
pre-request script is awaited, which makes the sandbox the only place a
|
|
147
|
+
blocking refresh can happen during a run. Hence a pre-request module.
|
|
148
|
+
|
|
149
|
+
The retry is the same story. Newman has no retry hook, so replaying a failed
|
|
150
|
+
request has to happen inside the sandbox, via `postman.setNextRequest`. The
|
|
151
|
+
generated snippet carries a guard variable: without one, a permanently
|
|
152
|
+
rejected token retries forever.
|
|
153
|
+
|
|
154
|
+
### A reporting quirk worth knowing
|
|
155
|
+
|
|
156
|
+
When a pre-request script issues its own `pm.sendRequest`, Newman reports the
|
|
157
|
+
item's execution **twice** in `summary.run.executions`. Both entries carry the
|
|
158
|
+
same `cursor.httpRequestId`, and the target server receives the request once.
|
|
159
|
+
|
|
160
|
+
Verified directly: a one-item collection produces two identical execution
|
|
161
|
+
entries while the mock API records a single request. If you assert on
|
|
162
|
+
execution counts in your own pipeline, deduplicate by `cursor.httpRequestId`
|
|
163
|
+
or you will chase a duplicate that was never sent. The tests here assert on
|
|
164
|
+
what the mock servers received instead.
|
|
165
|
+
|
|
166
|
+
## API
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
import {
|
|
170
|
+
TokenManager, // the refresh logic, no Newman dependency
|
|
171
|
+
createAuthorizedFetch, // fetch wrapper: proactive + reactive + one retry
|
|
172
|
+
withOAuthRefresh, // inject both scripts into a collection in memory
|
|
173
|
+
preRequestScript, // the module, as text
|
|
174
|
+
retryOn401Script // the retry snippet, as text
|
|
175
|
+
} from 'newman-oauth-refresh'
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`TokenManager` and `createAuthorizedFetch` have no Newman dependency, so they
|
|
179
|
+
work in any Node code that needs a token kept fresh:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
const tokens = new TokenManager({
|
|
183
|
+
tokenUrl: 'https://id.example.com/oauth2/token',
|
|
184
|
+
clientId, clientSecret, refreshToken,
|
|
185
|
+
credentials: 'body', // or 'basic'
|
|
186
|
+
headers: { 'User-Agent': 'acme-ci/2.1' }, // sent on the refresh call
|
|
187
|
+
marginMs: 60_000
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const fetch = createAuthorizedFetch(tokens)
|
|
191
|
+
const res = await fetch('https://api.example.com/orders')
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Collection variables
|
|
195
|
+
|
|
196
|
+
| Variable | Default | Meaning |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `oauthTokenUrl` | required | token endpoint |
|
|
199
|
+
| `oauthClientId` | | client id |
|
|
200
|
+
| `oauthClientSecret` | | client secret |
|
|
201
|
+
| `oauthRefreshToken` | | enables `grant_type=refresh_token` |
|
|
202
|
+
| `oauthScope` | | scope string |
|
|
203
|
+
| `oauthCredentials` | `body` | `body` or `basic` |
|
|
204
|
+
| `oauthMarginSec` | `60` | refresh this long before `exp` |
|
|
205
|
+
| `oauthExtraHeaders` | | JSON object of headers for the refresh call |
|
|
206
|
+
|
|
207
|
+
Set `oauthAccessToken` in your requests as `Bearer {{oauthAccessToken}}`.
|
|
208
|
+
|
|
209
|
+
## Limitations
|
|
210
|
+
|
|
211
|
+
- **A request body that is a stream cannot be retried.** The retry re-sends the
|
|
212
|
+
body, and a stream is single use, so the replay would go out empty. Buffer
|
|
213
|
+
first; `isReplayable(body)` tells you which case you are in.
|
|
214
|
+
- **No `authorization_code` flow.** This refreshes tokens, it does not perform
|
|
215
|
+
an interactive grant. Obtain the first refresh token out of band.
|
|
216
|
+
- **Secrets come from you.** Pass them as environment variables. The package
|
|
217
|
+
never persists one, but it cannot stop your CI logs from printing what you
|
|
218
|
+
hand it.
|
|
219
|
+
- **The sandbox module is ES5.** Postman's sandbox is not Node, so the
|
|
220
|
+
pre-request module avoids `Buffer`, imports and modern syntax deliberately.
|
|
221
|
+
|
|
222
|
+
## Tests
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npm test # 40 tests
|
|
226
|
+
npm run premise # is the problem still real?
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Every test runs against a real OAuth2 token endpoint and a real protected API
|
|
230
|
+
on real sockets, not a stubbed `fetch`. The three failure modes above are all
|
|
231
|
+
claims about what goes *on the wire* — which headers survive, where the
|
|
232
|
+
credentials are placed, whether an expired token is noticed — and a stub would
|
|
233
|
+
agree with any implementation, including a broken one.
|
|
234
|
+
|
|
235
|
+
The Newman tests drive actual Newman, for the same reason: this package's whole
|
|
236
|
+
premise is a claim about Newman's behaviour.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT. See [LICENSE](LICENSE).
|
|
241
|
+
|
|
242
|
+
Shivam Mahajan, https://linkedin.com/in/shivam-mahajan-a8005a205
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newman-oauth-refresh",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "OAuth2 token refresh for Newman collection runs: proactive for JWTs, reactive on 401 for opaque tokens, memory only",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Shivam Mahajan (https://linkedin.com/in/shivam-mahajan-a8005a205)",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test",
|
|
20
|
+
"premise": "node scripts/premise.mjs",
|
|
21
|
+
"prepublishOnly": "npm test && npm run premise"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"newman",
|
|
25
|
+
"postman",
|
|
26
|
+
"oauth2",
|
|
27
|
+
"token-refresh",
|
|
28
|
+
"jwt",
|
|
29
|
+
"api-testing"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/gl1tchdust/newman-oauth-refresh.git"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"newman": "^6.2.1"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/gl1tchdust/newman-oauth-refresh#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/gl1tchdust/newman-oauth-refresh/issues"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fetch wrapper that attaches a bearer token and recovers from a 401 once.
|
|
3
|
+
*
|
|
4
|
+
* Two paths, because tokens come in two shapes:
|
|
5
|
+
*
|
|
6
|
+
* Proactive A JWT says when it expires, so refresh just before. The
|
|
7
|
+
* request never goes out with a token that dies in flight.
|
|
8
|
+
*
|
|
9
|
+
* Reactive An opaque token says nothing. The only way to learn it has
|
|
10
|
+
* expired is to be told, so take the 401, refresh, send the
|
|
11
|
+
* same request again, and carry on.
|
|
12
|
+
*
|
|
13
|
+
* Reactive alone is not enough: it turns every expiry into a visible failed
|
|
14
|
+
* request, and some APIs rate-limit or alert on 401s. Proactive alone is not
|
|
15
|
+
* enough either: it cannot work for opaque tokens, and clock skew between you
|
|
16
|
+
* and the issuer will eventually beat any margin you pick. Both, or you have
|
|
17
|
+
* a gap.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Exactly one retry. Not a loop.
|
|
22
|
+
*
|
|
23
|
+
* If the second 401 arrives, the token is not the problem: the credential is
|
|
24
|
+
* wrong, the scope is missing, or the resource genuinely is not yours. Retrying
|
|
25
|
+
* past that point turns an authorization bug into a self-inflicted flood
|
|
26
|
+
* against the token endpoint, and hides the real error behind a timeout.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_RETRIES = 1
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {import('./token-manager.js').TokenManager} tokenManager
|
|
32
|
+
* @param {object} [options]
|
|
33
|
+
* @param {Function} [options.fetchImpl]
|
|
34
|
+
* @param {string} [options.headerName] default Authorization
|
|
35
|
+
* @param {string} [options.scheme] default Bearer
|
|
36
|
+
* @param {Function} [options.onRetry] observability hook
|
|
37
|
+
* @returns {(url: string, init?: object) => Promise<Response>}
|
|
38
|
+
*/
|
|
39
|
+
export function createAuthorizedFetch (tokenManager, options = {}) {
|
|
40
|
+
const {
|
|
41
|
+
fetchImpl = globalThis.fetch,
|
|
42
|
+
headerName = 'Authorization',
|
|
43
|
+
scheme = 'Bearer',
|
|
44
|
+
onRetry = null
|
|
45
|
+
} = options
|
|
46
|
+
|
|
47
|
+
if (typeof fetchImpl !== 'function') {
|
|
48
|
+
throw new TypeError('no fetch available; pass fetchImpl (Node 18+ has global fetch)')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return async function authorizedFetch (url, init = {}) {
|
|
52
|
+
// Proactive. Refreshes only when the token is near expiry.
|
|
53
|
+
let token = await tokenManager.getAccessToken()
|
|
54
|
+
|
|
55
|
+
const send = (bearer) => fetchImpl(url, {
|
|
56
|
+
...init,
|
|
57
|
+
headers: {
|
|
58
|
+
// Caller headers first: a per-request header must not be lost just
|
|
59
|
+
// because this wrapper has opinions about Authorization.
|
|
60
|
+
...(init.headers || {}),
|
|
61
|
+
[headerName]: `${scheme} ${bearer}`
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
let response = await send(token)
|
|
66
|
+
|
|
67
|
+
for (let attempt = 0; attempt < MAX_RETRIES && response.status === 401; attempt++) {
|
|
68
|
+
// Reactive. The server has told us the token is no good; believe it
|
|
69
|
+
// rather than trusting our own expiry arithmetic.
|
|
70
|
+
token = await tokenManager.refresh('reactive-401')
|
|
71
|
+
if (onRetry) { onRetry({ url, attempt: attempt + 1 }) }
|
|
72
|
+
response = await send(token)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return response
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Is this request body safe to send a second time?
|
|
81
|
+
*
|
|
82
|
+
* A retry re-sends the body. Strings, buffers and URLSearchParams can be read
|
|
83
|
+
* twice; a stream cannot, and the retry would go out with an empty body and
|
|
84
|
+
* fail in a way that looks like a server bug. Callers sending streams should
|
|
85
|
+
* buffer first, and this is here so they can check rather than find out.
|
|
86
|
+
*
|
|
87
|
+
* @param {*} body
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function isReplayable (body) {
|
|
91
|
+
if (body === undefined || body === null) { return true }
|
|
92
|
+
if (typeof body === 'string') { return true }
|
|
93
|
+
if (body instanceof URLSearchParams) { return true }
|
|
94
|
+
if (ArrayBuffer.isView(body) || body instanceof ArrayBuffer) { return true }
|
|
95
|
+
// ReadableStream, Readable, FormData with a stream part: single use.
|
|
96
|
+
return false
|
|
97
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
export { TokenManager, RefreshError } from './token-manager.js'
|
|
6
|
+
export { createAuthorizedFetch, isReplayable } from './authorized-fetch.js'
|
|
7
|
+
export { decodePayload, expiryMs, isExpiring } from './jwt.js'
|
|
8
|
+
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The pre-request module, as text, ready to paste into a collection's
|
|
13
|
+
* Pre-request Script tab or to inject programmatically.
|
|
14
|
+
*
|
|
15
|
+
* This is the only `readFileSync` in the package, and it reads this package's
|
|
16
|
+
* own source. No token ever touches the filesystem; see the test that asserts
|
|
17
|
+
* it.
|
|
18
|
+
*
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function preRequestScript () {
|
|
22
|
+
return readFileSync(join(here, 'postman', 'prerequest.js'), 'utf8')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The test-script snippet that retries a request once after a 401.
|
|
27
|
+
*
|
|
28
|
+
* Newman has no retry hook, and it does not await async event handlers, so
|
|
29
|
+
* neither a wrapper nor a `beforeRequest` listener can replay a failed
|
|
30
|
+
* request. `postman.setNextRequest` can, because it runs inside the sandbox
|
|
31
|
+
* where the collection's own control flow lives.
|
|
32
|
+
*
|
|
33
|
+
* The guard variable matters. Without it a permanently rejected token
|
|
34
|
+
* retries forever, turning an authorization bug into an infinite loop
|
|
35
|
+
* against the token endpoint.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} [options]
|
|
38
|
+
* @param {string} [options.itemName] the request's own name; defaults to the
|
|
39
|
+
* runtime value, which is correct unless the request is renamed mid-run
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function retryOn401Script (options = {}) {
|
|
43
|
+
const target = options.itemName
|
|
44
|
+
? JSON.stringify(options.itemName)
|
|
45
|
+
: 'pm.info.requestName'
|
|
46
|
+
|
|
47
|
+
return [
|
|
48
|
+
'// newman-oauth-refresh: retry once after a 401.',
|
|
49
|
+
'// Pair with the pre-request module, which does the refreshing.',
|
|
50
|
+
'if (pm.response.code === 401) {',
|
|
51
|
+
" var key = 'oauthRetried:' + pm.info.requestName;",
|
|
52
|
+
' if (!pm.variables.get(key)) {',
|
|
53
|
+
" pm.variables.set(key, '1');",
|
|
54
|
+
" pm.variables.set('oauthAccessToken', ''); // force a refresh",
|
|
55
|
+
" pm.variables.set('oauthAccessTokenExpiresAt', '');",
|
|
56
|
+
` postman.setNextRequest(${target});`,
|
|
57
|
+
' }',
|
|
58
|
+
'} else {',
|
|
59
|
+
" pm.variables.unset('oauthRetried:' + pm.info.requestName);",
|
|
60
|
+
'}'
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Inject both scripts into a collection object in memory.
|
|
66
|
+
*
|
|
67
|
+
* Returns a new collection; the input is not modified. Useful when you run
|
|
68
|
+
* Newman as a library and would rather not hand-edit the collection file,
|
|
69
|
+
* which also keeps the scripts out of version control alongside your tests.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} collection a Postman v2.1 collection
|
|
72
|
+
* @param {object} [options]
|
|
73
|
+
* @param {boolean} [options.retryOn401] also add the retry snippet to each item
|
|
74
|
+
* @returns {object}
|
|
75
|
+
*/
|
|
76
|
+
export function withOAuthRefresh (collection, options = {}) {
|
|
77
|
+
const { retryOn401 = true } = options
|
|
78
|
+
const next = structuredClone(collection)
|
|
79
|
+
|
|
80
|
+
next.event = (next.event || []).filter((e) => e.listen !== 'prerequest')
|
|
81
|
+
next.event.push({
|
|
82
|
+
listen: 'prerequest',
|
|
83
|
+
script: { type: 'text/javascript', exec: preRequestScript().split('\n') }
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (retryOn401) {
|
|
87
|
+
const snippet = retryOn401Script().split('\n')
|
|
88
|
+
const addTo = (items) => {
|
|
89
|
+
for (const item of items || []) {
|
|
90
|
+
if (item.item) { addTo(item.item); continue } // folder
|
|
91
|
+
item.event = (item.event || []).filter((e) => e.listen !== 'test')
|
|
92
|
+
item.event.push({
|
|
93
|
+
listen: 'test',
|
|
94
|
+
script: { type: 'text/javascript', exec: snippet }
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
addTo(next.item)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return next
|
|
102
|
+
}
|
package/src/jwt.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the expiry out of a JWT, without verifying it.
|
|
3
|
+
*
|
|
4
|
+
* Verification is deliberately absent. We are the client that was handed this
|
|
5
|
+
* token, not the resource server that has to trust it. We do not have the
|
|
6
|
+
* signing key and could not check it if we wanted to. The only question here
|
|
7
|
+
* is "roughly when does the issuer say this stops working", so the right
|
|
8
|
+
* amount of cryptography is none.
|
|
9
|
+
*
|
|
10
|
+
* Treating `exp` as advisory is also why a bad signature is not an error
|
|
11
|
+
* worth throwing on: if the token is junk, the API will say so, and the
|
|
12
|
+
* reactive 401 path handles it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decode a JWT payload. Returns null for anything that is not a JWT, which
|
|
17
|
+
* includes every opaque token, and that is the normal case rather than an
|
|
18
|
+
* error. Opaque tokens are handled reactively instead.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} token
|
|
21
|
+
* @returns {object|null}
|
|
22
|
+
*/
|
|
23
|
+
export function decodePayload (token) {
|
|
24
|
+
if (typeof token !== 'string') { return null }
|
|
25
|
+
|
|
26
|
+
const parts = token.split('.')
|
|
27
|
+
if (parts.length !== 3) { return null }
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// base64url, and Buffer tolerates the missing padding.
|
|
31
|
+
const json = Buffer.from(parts[1], 'base64url').toString('utf8')
|
|
32
|
+
const payload = JSON.parse(json)
|
|
33
|
+
// A JWT payload is a JSON object. An array or a bare number is not one,
|
|
34
|
+
// and would make `payload.exp` silently undefined further down.
|
|
35
|
+
return (payload && typeof payload === 'object' && !Array.isArray(payload))
|
|
36
|
+
? payload
|
|
37
|
+
: null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Expiry as epoch milliseconds, or null when the token does not carry one.
|
|
45
|
+
*
|
|
46
|
+
* Null means "no opinion", not "expired". An opaque token has no expiry we
|
|
47
|
+
* can read, and treating that as expired would refresh before every single
|
|
48
|
+
* request.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} token
|
|
51
|
+
* @returns {number|null}
|
|
52
|
+
*/
|
|
53
|
+
export function expiryMs (token) {
|
|
54
|
+
const payload = decodePayload(token)
|
|
55
|
+
if (!payload) { return null }
|
|
56
|
+
|
|
57
|
+
const { exp } = payload
|
|
58
|
+
// RFC 7519 says exp is NumericDate, seconds since epoch. Some issuers send
|
|
59
|
+
// it as a string. Accept both; reject anything else rather than producing
|
|
60
|
+
// NaN, which compares false against everything and would disable refresh
|
|
61
|
+
// without any visible failure.
|
|
62
|
+
const seconds = typeof exp === 'string' ? Number(exp) : exp
|
|
63
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) { return null }
|
|
64
|
+
|
|
65
|
+
return seconds * 1000
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Is this token within `marginMs` of expiring?
|
|
70
|
+
*
|
|
71
|
+
* False for tokens with no readable expiry. The caller decides what to do
|
|
72
|
+
* with those; this function will not guess.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} token
|
|
75
|
+
* @param {number} marginMs how long before expiry to start saying yes
|
|
76
|
+
* @param {number} [now] epoch ms, injectable so tests do not sleep
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function isExpiring (token, marginMs, now = Date.now()) {
|
|
80
|
+
const expires = expiryMs(token)
|
|
81
|
+
if (expires === null) { return false }
|
|
82
|
+
return expires - now <= marginMs
|
|
83
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* newman-oauth-refresh: collection pre-request module.
|
|
4
|
+
*
|
|
5
|
+
* Paste this into your collection's Pre-request Script tab, set the variables
|
|
6
|
+
* listed below, and set each request's Authorization header to
|
|
7
|
+
* `Bearer {{oauthAccessToken}}`.
|
|
8
|
+
*
|
|
9
|
+
* Why a pre-request script rather than a Newman event hook: Newman does not
|
|
10
|
+
* await async event handlers, so a `beforeRequest` listener cannot refresh a
|
|
11
|
+
* token before the request goes out. `pm.sendRequest` inside a pre-request
|
|
12
|
+
* script *is* awaited, which makes this the only place a blocking refresh can
|
|
13
|
+
* happen inside a Newman run.
|
|
14
|
+
*
|
|
15
|
+
* Required variables:
|
|
16
|
+
* oauthTokenUrl token endpoint
|
|
17
|
+
*
|
|
18
|
+
* Optional variables:
|
|
19
|
+
* oauthClientId client id
|
|
20
|
+
* oauthClientSecret client secret
|
|
21
|
+
* oauthRefreshToken enables grant_type=refresh_token
|
|
22
|
+
* oauthScope scope string
|
|
23
|
+
* oauthCredentials 'body' (default) or 'basic'
|
|
24
|
+
* oauthMarginSec refresh this many seconds before exp (default 60)
|
|
25
|
+
* oauthExtraHeaders JSON object of headers to send on the refresh call
|
|
26
|
+
*
|
|
27
|
+
* Written for the Postman sandbox: ES5, no imports, no Buffer, no fs.
|
|
28
|
+
*/
|
|
29
|
+
(function () {
|
|
30
|
+
var TOKEN_VAR = 'oauthAccessToken'
|
|
31
|
+
var EXPIRY_VAR = 'oauthAccessTokenExpiresAt'
|
|
32
|
+
|
|
33
|
+
function v (name, fallback) {
|
|
34
|
+
var value = pm.variables.get(name)
|
|
35
|
+
return (value === undefined || value === null || value === '') ? fallback : value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
var tokenUrl = v('oauthTokenUrl', null)
|
|
39
|
+
if (!tokenUrl) {
|
|
40
|
+
throw new Error('newman-oauth-refresh: set the oauthTokenUrl variable')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
var clientId = v('oauthClientId', null)
|
|
44
|
+
var clientSecret = v('oauthClientSecret', null)
|
|
45
|
+
var refreshToken = v('oauthRefreshToken', null)
|
|
46
|
+
var scope = v('oauthScope', null)
|
|
47
|
+
var credentials = v('oauthCredentials', 'body')
|
|
48
|
+
var marginSec = Number(v('oauthMarginSec', 60))
|
|
49
|
+
|
|
50
|
+
var extraHeaders = {}
|
|
51
|
+
try {
|
|
52
|
+
var rawHeaders = v('oauthExtraHeaders', null)
|
|
53
|
+
if (rawHeaders) {
|
|
54
|
+
extraHeaders = typeof rawHeaders === 'string' ? JSON.parse(rawHeaders) : rawHeaders
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new Error('newman-oauth-refresh: oauthExtraHeaders is not valid JSON')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- expiry ---------------------------------------------------------------
|
|
61
|
+
//
|
|
62
|
+
// Decoded, never verified. We are the client that was handed this token, not
|
|
63
|
+
// the resource server that has to trust it.
|
|
64
|
+
|
|
65
|
+
function b64urlDecode (input) {
|
|
66
|
+
var s = String(input).replace(/-/g, '+').replace(/_/g, '/')
|
|
67
|
+
while (s.length % 4 !== 0) { s += '=' }
|
|
68
|
+
return atob(s)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function jwtExpiryMs (token) {
|
|
72
|
+
if (typeof token !== 'string') { return null }
|
|
73
|
+
var parts = token.split('.')
|
|
74
|
+
if (parts.length !== 3) { return null } // opaque token: no opinion
|
|
75
|
+
try {
|
|
76
|
+
var payload = JSON.parse(b64urlDecode(parts[1]))
|
|
77
|
+
var exp = typeof payload.exp === 'string' ? Number(payload.exp) : payload.exp
|
|
78
|
+
if (typeof exp !== 'number' || !isFinite(exp)) { return null }
|
|
79
|
+
return exp * 1000
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function needsRefresh () {
|
|
86
|
+
var token = pm.variables.get(TOKEN_VAR)
|
|
87
|
+
if (!token) { return true }
|
|
88
|
+
|
|
89
|
+
var now = Date.now()
|
|
90
|
+
var marginMs = marginSec * 1000
|
|
91
|
+
|
|
92
|
+
// A JWT carries its own expiry.
|
|
93
|
+
var exp = jwtExpiryMs(token)
|
|
94
|
+
if (exp !== null) { return (exp - now) <= marginMs }
|
|
95
|
+
|
|
96
|
+
// An opaque token does not, so fall back to what the token endpoint said
|
|
97
|
+
// when it issued this one. Without either signal, do not refresh: the
|
|
98
|
+
// 401 retry snippet in the README covers that case, and refreshing before
|
|
99
|
+
// every request would defeat the point.
|
|
100
|
+
var storedExpiry = Number(pm.variables.get(EXPIRY_VAR))
|
|
101
|
+
if (storedExpiry) { return (storedExpiry - now) <= marginMs }
|
|
102
|
+
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- refresh --------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function buildRequest () {
|
|
109
|
+
var form = []
|
|
110
|
+
var grantType = refreshToken ? 'refresh_token' : 'client_credentials'
|
|
111
|
+
form.push('grant_type=' + encodeURIComponent(grantType))
|
|
112
|
+
if (refreshToken) { form.push('refresh_token=' + encodeURIComponent(refreshToken)) }
|
|
113
|
+
if (scope) { form.push('scope=' + encodeURIComponent(scope)) }
|
|
114
|
+
|
|
115
|
+
// Caller headers first; the protocol bits below have to win. Everything
|
|
116
|
+
// else the caller set is passed through, which is the point: dropping
|
|
117
|
+
// User-Agent or a required X-Client-ID is how these refresh calls get
|
|
118
|
+
// rejected by API gateways.
|
|
119
|
+
var header = {}
|
|
120
|
+
for (var k in extraHeaders) {
|
|
121
|
+
if (Object.prototype.hasOwnProperty.call(extraHeaders, k)) {
|
|
122
|
+
header[k] = extraHeaders[k]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
header['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
126
|
+
|
|
127
|
+
if (credentials === 'basic') {
|
|
128
|
+
header.Authorization = 'Basic ' + btoa((clientId || '') + ':' + (clientSecret || ''))
|
|
129
|
+
} else {
|
|
130
|
+
// RFC 6749 2.3.1 allows credentials in the body, and some providers
|
|
131
|
+
// accept nothing else. Forcing Basic breaks those outright.
|
|
132
|
+
if (clientId) { form.push('client_id=' + encodeURIComponent(clientId)) }
|
|
133
|
+
if (clientSecret) { form.push('client_secret=' + encodeURIComponent(clientSecret)) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
url: tokenUrl,
|
|
138
|
+
method: 'POST',
|
|
139
|
+
header: header,
|
|
140
|
+
body: { mode: 'raw', raw: form.join('&') }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!needsRefresh()) { return }
|
|
145
|
+
|
|
146
|
+
pm.sendRequest(buildRequest(), function (err, res) {
|
|
147
|
+
if (err) {
|
|
148
|
+
throw new Error('newman-oauth-refresh: token request failed: ' + err)
|
|
149
|
+
}
|
|
150
|
+
if (res.code < 200 || res.code >= 300) {
|
|
151
|
+
throw new Error('newman-oauth-refresh: token endpoint returned ' + res.code +
|
|
152
|
+
': ' + String(res.text()).slice(0, 200))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
var body = res.json()
|
|
156
|
+
if (!body.access_token) {
|
|
157
|
+
throw new Error('newman-oauth-refresh: token response has no access_token')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// pm.variables is run-scoped and in memory. Deliberately NOT
|
|
161
|
+
// pm.environment.set with a persisted environment file: a token written
|
|
162
|
+
// to disk outlives the run that needed it, on every CI runner it touches.
|
|
163
|
+
pm.variables.set(TOKEN_VAR, body.access_token)
|
|
164
|
+
|
|
165
|
+
if (typeof body.expires_in === 'number') {
|
|
166
|
+
pm.variables.set(EXPIRY_VAR, String(Date.now() + body.expires_in * 1000))
|
|
167
|
+
} else {
|
|
168
|
+
pm.variables.set(EXPIRY_VAR, '')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Rotation: a provider that issues a new refresh token invalidates the
|
|
172
|
+
// old one, so keeping the original breaks the *second* refresh.
|
|
173
|
+
if (body.refresh_token) {
|
|
174
|
+
pm.variables.set('oauthRefreshToken', body.refresh_token)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
})()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { isExpiring } from './jwt.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when the token endpoint refuses to issue a token.
|
|
5
|
+
*
|
|
6
|
+
* Separate from a failure of the request you were actually trying to make,
|
|
7
|
+
* because the two need different responses: a 401 from your API means refresh
|
|
8
|
+
* and retry, a 401 from the token endpoint means your client credentials are
|
|
9
|
+
* wrong and retrying will never help.
|
|
10
|
+
*/
|
|
11
|
+
export class RefreshError extends Error {
|
|
12
|
+
constructor (message, { status, body } = {}) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'RefreshError'
|
|
15
|
+
this.status = status
|
|
16
|
+
this.body = body
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MARGIN_MS = 60_000
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Holds an OAuth2 access token and refreshes it.
|
|
24
|
+
*
|
|
25
|
+
* Memory only. Nothing here reads or writes a file, and there is a test that
|
|
26
|
+
* fails if that changes. A CLI that caches tokens on disk turns every CI
|
|
27
|
+
* runner, every laptop, and every container layer into a place a bearer token
|
|
28
|
+
* can be found later. The run is short; keeping the token in the process that
|
|
29
|
+
* needs it costs nothing.
|
|
30
|
+
*/
|
|
31
|
+
export class TokenManager {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} options
|
|
34
|
+
* @param {string} options.tokenUrl token endpoint
|
|
35
|
+
* @param {string} [options.clientId]
|
|
36
|
+
* @param {string} [options.clientSecret]
|
|
37
|
+
* @param {string} [options.refreshToken] for grant_type=refresh_token
|
|
38
|
+
* @param {string} [options.accessToken] a token you already hold
|
|
39
|
+
* @param {string} [options.scope]
|
|
40
|
+
* @param {'body'|'basic'} [options.credentials] where client creds go
|
|
41
|
+
* @param {object} [options.headers] extra headers for the refresh call
|
|
42
|
+
* @param {number} [options.marginMs] refresh this long before exp
|
|
43
|
+
* @param {'refresh_token'|'client_credentials'} [options.grantType]
|
|
44
|
+
* @param {Function} [options.fetchImpl] injectable for tests
|
|
45
|
+
* @param {Function} [options.now] injectable clock
|
|
46
|
+
* @param {Function} [options.onRefresh] observability hook
|
|
47
|
+
*/
|
|
48
|
+
constructor (options = {}) {
|
|
49
|
+
const {
|
|
50
|
+
tokenUrl,
|
|
51
|
+
clientId,
|
|
52
|
+
clientSecret,
|
|
53
|
+
refreshToken,
|
|
54
|
+
accessToken = null,
|
|
55
|
+
scope,
|
|
56
|
+
credentials = 'body',
|
|
57
|
+
headers = {},
|
|
58
|
+
marginMs = DEFAULT_MARGIN_MS,
|
|
59
|
+
grantType = refreshToken ? 'refresh_token' : 'client_credentials',
|
|
60
|
+
fetchImpl = globalThis.fetch,
|
|
61
|
+
now = () => Date.now(),
|
|
62
|
+
onRefresh = null
|
|
63
|
+
} = options
|
|
64
|
+
|
|
65
|
+
if (!tokenUrl) { throw new TypeError('tokenUrl is required') }
|
|
66
|
+
if (credentials !== 'body' && credentials !== 'basic') {
|
|
67
|
+
throw new TypeError(`credentials must be 'body' or 'basic', got ${credentials}`)
|
|
68
|
+
}
|
|
69
|
+
if (typeof fetchImpl !== 'function') {
|
|
70
|
+
throw new TypeError('no fetch available; pass fetchImpl (Node 18+ has global fetch)')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.tokenUrl = tokenUrl
|
|
74
|
+
this.clientId = clientId
|
|
75
|
+
this.clientSecret = clientSecret
|
|
76
|
+
this.scope = scope
|
|
77
|
+
this.credentials = credentials
|
|
78
|
+
this.headers = headers
|
|
79
|
+
this.marginMs = marginMs
|
|
80
|
+
this.grantType = grantType
|
|
81
|
+
this.onRefresh = onRefresh
|
|
82
|
+
|
|
83
|
+
this._fetch = fetchImpl
|
|
84
|
+
this._now = now
|
|
85
|
+
|
|
86
|
+
// The only places a token lives. No disk, no module-level cache that
|
|
87
|
+
// outlives the instance.
|
|
88
|
+
this._accessToken = accessToken
|
|
89
|
+
this._refreshToken = refreshToken
|
|
90
|
+
// Set when the endpoint tells us expires_in but the token is opaque, so
|
|
91
|
+
// even opaque tokens get proactive refresh when the server is helpful.
|
|
92
|
+
this._expiresAtMs = null
|
|
93
|
+
// Single-flight guard. A collection run fires requests back to back; if
|
|
94
|
+
// three of them notice an expiring token at once, one refresh should go
|
|
95
|
+
// out, not three. Concurrent refreshes also race to store the rotated
|
|
96
|
+
// refresh token, and the loser's copy is already invalid.
|
|
97
|
+
this._inFlight = null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The token currently held, without triggering a refresh. */
|
|
101
|
+
get accessToken () { return this._accessToken }
|
|
102
|
+
|
|
103
|
+
/** The refresh token currently held. Rotated values are kept up to date. */
|
|
104
|
+
get refreshToken () { return this._refreshToken }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Should we refresh before using this token?
|
|
108
|
+
*
|
|
109
|
+
* Two independent signals, because tokens come in two shapes. A JWT carries
|
|
110
|
+
* its own `exp` and we read it. An opaque token carries nothing, but the
|
|
111
|
+
* token endpoint usually returned `expires_in`, so we remember that.
|
|
112
|
+
* Neither available means no opinion, and the reactive 401 path covers it.
|
|
113
|
+
*/
|
|
114
|
+
needsRefresh () {
|
|
115
|
+
if (!this._accessToken) { return true }
|
|
116
|
+
|
|
117
|
+
const now = this._now()
|
|
118
|
+
if (isExpiring(this._accessToken, this.marginMs, now)) { return true }
|
|
119
|
+
if (this._expiresAtMs !== null && this._expiresAtMs - now <= this.marginMs) {
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A usable access token, refreshing first if it is close to expiry.
|
|
127
|
+
*
|
|
128
|
+
* This is the proactive path. For a JWT it means a request is never sent
|
|
129
|
+
* with a token that is about to die mid-flight, which is the failure that
|
|
130
|
+
* makes expiry bugs intermittent and miserable to reproduce.
|
|
131
|
+
*/
|
|
132
|
+
async getAccessToken () {
|
|
133
|
+
if (this.needsRefresh()) { await this.refresh('proactive') }
|
|
134
|
+
return this._accessToken
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Refresh now, regardless of expiry. Used by the reactive 401 path.
|
|
139
|
+
*
|
|
140
|
+
* Concurrent callers share one in-flight request rather than stampeding
|
|
141
|
+
* the token endpoint.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} [reason] recorded for the onRefresh hook
|
|
144
|
+
*/
|
|
145
|
+
async refresh (reason = 'forced') {
|
|
146
|
+
if (this._inFlight) { return this._inFlight }
|
|
147
|
+
|
|
148
|
+
this._inFlight = this._performRefresh(reason)
|
|
149
|
+
.finally(() => { this._inFlight = null })
|
|
150
|
+
return this._inFlight
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async _performRefresh (reason) {
|
|
154
|
+
const body = new URLSearchParams()
|
|
155
|
+
body.set('grant_type', this.grantType)
|
|
156
|
+
|
|
157
|
+
if (this.grantType === 'refresh_token') {
|
|
158
|
+
if (!this._refreshToken) {
|
|
159
|
+
throw new RefreshError('grant_type is refresh_token but no refresh token is held')
|
|
160
|
+
}
|
|
161
|
+
body.set('refresh_token', this._refreshToken)
|
|
162
|
+
}
|
|
163
|
+
if (this.scope) { body.set('scope', this.scope) }
|
|
164
|
+
|
|
165
|
+
// Custom headers first, ours second. The protocol bits have to win:
|
|
166
|
+
// the body encoding is ours to declare, and in basic mode the
|
|
167
|
+
// Authorization header carries the client credentials. Everything else
|
|
168
|
+
// the caller set is passed through untouched, which is the point.
|
|
169
|
+
const headers = { ...this.headers }
|
|
170
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
171
|
+
|
|
172
|
+
if (this.credentials === 'basic') {
|
|
173
|
+
const pair = `${this.clientId ?? ''}:${this.clientSecret ?? ''}`
|
|
174
|
+
headers.Authorization = `Basic ${Buffer.from(pair).toString('base64')}`
|
|
175
|
+
} else {
|
|
176
|
+
// RFC 6749 §2.3.1 permits credentials in the body, and plenty of
|
|
177
|
+
// providers accept only this form. Forcing Basic is a real-world
|
|
178
|
+
// failure, not a style preference.
|
|
179
|
+
if (this.clientId !== undefined) { body.set('client_id', this.clientId) }
|
|
180
|
+
if (this.clientSecret !== undefined) { body.set('client_secret', this.clientSecret) }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const response = await this._fetch(this.tokenUrl, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers,
|
|
186
|
+
body: body.toString()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const text = await response.text()
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new RefreshError(
|
|
192
|
+
`token endpoint returned ${response.status}`,
|
|
193
|
+
{ status: response.status, body: text.slice(0, 500) }
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let payload
|
|
198
|
+
try {
|
|
199
|
+
payload = JSON.parse(text)
|
|
200
|
+
} catch {
|
|
201
|
+
throw new RefreshError('token endpoint did not return JSON',
|
|
202
|
+
{ status: response.status, body: text.slice(0, 500) })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!payload.access_token) {
|
|
206
|
+
throw new RefreshError('token response has no access_token',
|
|
207
|
+
{ status: response.status, body: text.slice(0, 500) })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this._accessToken = payload.access_token
|
|
211
|
+
|
|
212
|
+
// Rotation. Providers that rotate refresh tokens invalidate the old one
|
|
213
|
+
// on use, so keeping the original would break the *second* refresh and
|
|
214
|
+
// look like an intermittent failure an hour into a long run.
|
|
215
|
+
if (payload.refresh_token) { this._refreshToken = payload.refresh_token }
|
|
216
|
+
|
|
217
|
+
this._expiresAtMs = typeof payload.expires_in === 'number'
|
|
218
|
+
? this._now() + payload.expires_in * 1000
|
|
219
|
+
: null
|
|
220
|
+
|
|
221
|
+
if (this.onRefresh) {
|
|
222
|
+
this.onRefresh({
|
|
223
|
+
reason,
|
|
224
|
+
expiresIn: payload.expires_in ?? null,
|
|
225
|
+
rotated: Boolean(payload.refresh_token)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this._accessToken
|
|
230
|
+
}
|
|
231
|
+
}
|