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 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
- # Temporary Holding Version
1
+ # newman-oauth-refresh
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![ci](https://github.com/gl1tchdust/newman-oauth-refresh/actions/workflows/ci.yml/badge.svg)](https://github.com/gl1tchdust/newman-oauth-refresh/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/newman-oauth-refresh)](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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }