@wgroovy/sf-jwt 1.2.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +417 -0
  3. package/dist/sf-jwt.js +230 -0
  4. package/package.json +47 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wgroovy
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 THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,417 @@
1
+ # @wgroovy/sf-jwt
2
+
3
+ A zero-dependency Node CLI for JSON Web Tokens, specialising in the two Salesforce flows that consume them.
4
+
5
+ | Subcommand | What it does |
6
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
7
+ | `jwt-bearer-flow` | Proves an org's OAuth JWT bearer setup by performing the exchange for real |
8
+ | [`scv-auth-token`](#minting-a-telephony-integration-token) | Mints and verifies a token for the Telephony Integration REST API |
9
+ | [`encode`](#signing-and-reading-arbitrary-tokens) | Signs any header and payload you give it, with no opinion about the claims |
10
+ | [`decode`](#signing-and-reading-arbitrary-tokens) | Reads a token, and optionally checks its signature against a key or certificate |
11
+
12
+ `jwt-bearer-flow` is the default, so every invocation below works with or without naming it.
13
+
14
+ The speciality is the point. Salesforce's rejections are opaque, so `jwt-bearer-flow` mints a signed assertion, exchanges it at the token endpoint for real, and translates whatever comes back into a plain-language cause. The thing under test is your org's configuration — the External Client App, the certificate, and the user's authorization. Salesforce renders the verdict; this tool sets up a clean trial and interprets the answer.
15
+
16
+ `encode` and `decode` are the general-purpose half, for the tokens this tool has no opinion about — including Salesforce APIs it does not model yet. They contact nothing and judge no org.
17
+
18
+ ## Why not just use curl
19
+
20
+ Salesforce's rejections are opaque. A single `invalid_grant` covers a stale expiry, clock drift, a key that doesn't match the uploaded certificate, an unapproved user, a wrong audience, and IP restrictions — and the accompanying `error_description` frequently describes something other than the actual fault. The most common one, `"expired authorization code"`, involves no authorization code at all.
21
+
22
+ This tool maps those pairs to causes and remediation steps, checks your clock against Salesforce's, and confirms the issued token resolves to the user you expected.
23
+
24
+ ## Install
25
+
26
+ Node 22 or later, and no runtime dependencies.
27
+
28
+ ```bash
29
+ npm install -g @wgroovy/sf-jwt
30
+ sf-jwt --help
31
+ ```
32
+
33
+ Or without installing:
34
+
35
+ ```bash
36
+ npx -p @wgroovy/sf-jwt sf-jwt --help
37
+ ```
38
+
39
+ ## Setup
40
+
41
+ ### 1. Generate a key pair
42
+
43
+ ```bash
44
+ openssl genrsa -out server.key 2048
45
+ openssl req -new -x509 -key server.key -out server.crt -days 365 -subj "/CN=sf-jwt-validator"
46
+ ```
47
+
48
+ `server.key` stays with you and signs assertions. `server.crt` gets uploaded to Salesforce. Keep both out of version control — anyone holding the key can mint assertions for your org.
49
+
50
+ ### 2. Create the External Client App
51
+
52
+ In Salesforce Setup:
53
+
54
+ 1. **App Manager → New External Client App**, open **API (Enable OAuth Settings)**, then tick **Enable OAuth**
55
+ 2. Set a callback URL — `http://localhost:1717/OauthRedirect` works. The JWT flow never uses it, but the form requires one.
56
+ 3. Add the OAuth scopes you need. `Manage user data via APIs (api)` and `Perform requests at any time (refresh_token, offline_access)` cover most integrations.
57
+ 4. In **Flow Enablement**, tick **Enable JWT Bearer Flow** and upload `server.crt`.
58
+ 5. Save, then **wait up to 10 minutes** — a new app is not usable immediately, and querying it too early returns `invalid_client_id`.
59
+
60
+ On orgs still using the older model, this is a Connected App and the certificate goes under **Use digital signatures** instead. Everything else in this guide applies unchanged; Salesforce returns identical errors for both.
61
+
62
+ ### 3. Pre-authorize the user
63
+
64
+ Without this step you'll get `user hasn't approved this consumer`, because the JWT flow has no interactive consent screen.
65
+
66
+ 1. **External Client App Manager → your app → Policies tab**, click **Edit**; in **OAuth Policies**, set **Permitted Users** to `Admin approved users are pre-authorized`.
67
+ 2. Scroll up to **App Policies**, assign the integration user's **Profile** or a **Permission Set** to the app. Pre-authorization with nothing assigned authorizes nobody.
68
+
69
+ ### 4. Validate
70
+
71
+ ```bash
72
+ sf-jwt \
73
+ --iss 3MVG9...your-consumer-key \
74
+ --sub integration@your-org.com \
75
+ --key server.key
76
+ ```
77
+
78
+ A healthy setup prints the access token on stdout, and the verdict — including the identity it resolved to — on stderr:
79
+
80
+ ```
81
+ 00DHs0000012345!AQEAQK7...the.access.token
82
+
83
+ PASS Salesforce issued an access token (HTTP 200, 412ms).
84
+ user: integration@your-org.com
85
+ user id: 005Hs00000ABCDEabc
86
+ org id: 00DHs0000012345MAA
87
+ instance: https://your-org.my.salesforce.com
88
+ ```
89
+
90
+ Because the two are on separate channels, the token is capturable and the report still reaches you:
91
+
92
+ ```bash
93
+ TOKEN=$(sf-jwt --iss 3MVG9... --sub integration@your-org.com --key server.key)
94
+ curl -H "Authorization: Bearer $TOKEN" \
95
+ "https://your-org.my.salesforce.com/services/data/v62.0/sobjects/Account"
96
+ ```
97
+
98
+ A broken one has no token to print, so stdout stays empty and the exit code is 1:
99
+
100
+ ```
101
+ FAIL Salesforce rejected the assertion (HTTP 400, 389ms).
102
+ invalid_grant — user hasn't approved this consumer
103
+
104
+ Likely cause
105
+ The Subject User has never authorized this app, and the app is not configured
106
+ to pre-authorize users.
107
+
108
+ What to do
109
+ - External Client App Manager → your app → Policies → Edit → OAuth Policies →
110
+ set Permitted Users to "Admin approved users are pre-authorized".
111
+ (Connected Apps: Manage → Edit Policies.)
112
+ - Then assign the Subject User's Profile or a Permission Set under App
113
+ Policies, otherwise pre-authorization applies to nobody.
114
+ ```
115
+
116
+ ## Options
117
+
118
+ These belong to `jwt-bearer-flow`. Every other subcommand takes its own set, listed in its own section, and a flag offered to a subcommand it does not belong to is refused rather than ignored.
119
+
120
+ | Flag | Meaning | Default |
121
+ | ------------------- | ------------------------------------------------- | ------------------------------ |
122
+ | `--iss <key>` | External Client App consumer key | required |
123
+ | `--key <path>` | RSA private key PEM; `-` reads stdin | required |
124
+ | `--sub <username>` | Salesforce user to act as; omitted when unset | required in practice |
125
+ | `--aud <url>` | Audience claim | `https://login.salesforce.com` |
126
+ | `--sandbox` | Shorthand for `--aud https://test.salesforce.com` | off |
127
+ | `--exp <seconds>` | Seconds from now | `86400` |
128
+ | `--exp-at <epoch>` | Absolute expiry; overrides `--exp` | — |
129
+ | `--token-url <url>` | Override the endpoint derived from `--aud` | `<aud>/services/oauth2/token` |
130
+ | `--timeout <ms>` | Request timeout; no retries | `10000` |
131
+ | `--skip-userinfo` | Stop after the token exchange | off |
132
+ | `-v`, `--verbose` | Full trace on stderr, including HTTP headers | off |
133
+ | `--json` | Structured result on stdout, not the bare token | off |
134
+ | `--version` | Print the version and exit | — |
135
+
136
+ Sandboxes need `--sandbox`. Orgs with My Domain often keep `aud` as `login.salesforce.com` while posting to their own host — that's what `--token-url` is for. Experience Cloud sites use the full site URL, path included, as the audience.
137
+
138
+ Salesforce's documentation describes a five-minute ceiling on `exp`, and nearly every published example uses 300 seconds — but longer lifetimes are accepted in practice, and a full day works. The tool imposes no limit of its own and lets Salesforce judge.
139
+
140
+ Salesforce's documentation presents `sub` as an Experience Cloud concern, but a standard org needs it too: an assertion carrying only `iss`, `aud`, and `exp` is rejected with `app_not_found`, which points at the app when the real problem is the missing subject. Supply `--sub`. The flag stays optional so the tool asserts nothing Salesforce hasn't, and the claim is omitted from the assertion entirely rather than sent empty when you leave it off — but expect a rejection, which the tool translates rather than passing on at face value.
141
+
142
+ Every run stamps its version on stderr as its first line, so a pasted log identifies the build that produced it. `--version` prints the bare version to stdout on its own, and the help text names it too. Running with no arguments prints the help — unless `SF_JWT_*` variables are set, since configuring the whole run through the environment is a supported way to use the tool.
143
+
144
+ ## Verbose output
145
+
146
+ `-v` traces the whole exchange to stderr, leaving stdout free for the token:
147
+
148
+ ```
149
+ ── Claims ──────────────────────────────────────────────────────────────────
150
+ iss: 3MVG9...
151
+ sub: integration@your-org.com
152
+ aud: https://login.salesforce.com
153
+ exp: 1786207252
154
+ exp at: 2026-08-08T16:40:52.000Z (in 1d)
155
+ assertion: eyJhbGciOiJS… [544 chars, redacted — not a TTY]
156
+
157
+ ── HTTP request ────────────────────────────────────────────────────────────
158
+ POST https://login.salesforce.com/services/oauth2/token
159
+ content-type: application/x-www-form-urlencoded
160
+ accept: application/json
161
+
162
+ grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
163
+ assertion=eyJhbGciOiJS… [544 chars, redacted — not a TTY]
164
+
165
+ ── HTTP response ───────────────────────────────────────────────────────────
166
+ HTTP 400 Bad Request — 389ms
167
+ content-type: application/json;charset=UTF-8
168
+ date: Fri, 07 Aug 2026 16:40:54 GMT
169
+
170
+ {
171
+ "error": "invalid_client_id",
172
+ "error_description": "client identifier invalid"
173
+ }
174
+
175
+ ── Clock ───────────────────────────────────────────────────────────────────
176
+ drift: 1s versus Salesforce (±2s on a 389ms round trip)
177
+ ```
178
+
179
+ Drift is measured against the midpoint of the request window rather than its end, so round-trip latency isn't reported as skew. The stated tolerance is half the round trip plus a second for the `Date` header's whole-second resolution — on a slow connection, treat small numbers as noise.
180
+
181
+ ## Minting a Telephony Integration token
182
+
183
+ Salesforce's [Telephony Integration REST API](https://developer.salesforce.com/docs/atlas.en-us.voice_developer_guide.meta/voice_developer_guide/voice_rest_authorization.htm) — the Service Cloud Voice and Salesforce Voice APIs — does not use the OAuth JWT bearer flow. The signed JWT _is_ the bearer credential, presented directly with no exchange:
184
+
185
+ ```bash
186
+ sf-jwt scv-auth-token \
187
+ --iss 00DHs0000012345 \
188
+ --sub your_call_center_api_name \
189
+ --key server.key \
190
+ --my-domain your-org.my.salesforce.com
191
+ ```
192
+
193
+ Exactly one thing goes to stdout — the token — while the verdict goes to stderr, so the token stays capturable:
194
+
195
+ ```bash
196
+ TOKEN=$(sf-jwt scv-auth-token --iss 00DHs0000012345 --sub cc1 --key server.key \
197
+ --my-domain your-org.my.salesforce.com)
198
+ curl -H "Authorization: Bearer $TOKEN" "$TELEPHONY_ENDPOINT"
199
+ ```
200
+
201
+ The token is signed, not encrypted, so its header and claims decode in any JWT viewer. That is how every JWT works and is not a leak — what protects the token is that only a holder of the signing key can produce a signature Salesforce will accept, and the claims are just an org ID, a Call Center API Name, and timestamps. The secret is the assembled token: anyone holding it can call the API until it expires.
202
+
203
+ | Flag | Meaning | Default |
204
+ | -------------------- | ------------------------------------ | -------- |
205
+ | `--iss <org-id>` | Salesforce org ID, starts `00D` | required |
206
+ | `--sub <name>` | Call Center API Name | required |
207
+ | `--key <path>` | RSA private key PEM; `-` reads stdin | required |
208
+ | `--my-domain <host>` | Host to verify against | required |
209
+ | `--no-probe` | Mint without verifying | off |
210
+ | `--exp <seconds>` | Seconds from now | `3600` |
211
+ | `--exp-at <epoch>` | Absolute expiry; overrides `--exp` | — |
212
+ | `--jti` | Add a unique JWT ID | off |
213
+ | `--timeout <ms>` | Probe timeout | `10000` |
214
+ | `-v`, `--verbose` | Trace on stderr | off |
215
+ | `--json` | `{token, claims, probe}` on stdout | off |
216
+
217
+ ### How the token gets verified
218
+
219
+ Every telephony endpoint Salesforce documents changes something — there is no `GET`, no health check, nothing safe to read. So the token is verified with a request that cannot succeed: a voice call whose `callSubtype` is `sf-jwt-probe`, which is not one of the two values the field accepts.
220
+
221
+ `--my-domain` takes your org's My Domain host. The `https://` is optional, a trailing path is ignored, and the bare `{MyDomain}` works too — so pasting straight from the browser bar does the right thing:
222
+
223
+ | You pass | Probe goes to |
224
+ | ----------------------------------------------------- | ------------------------------------------ |
225
+ | `acme.my.salesforce.com` | `acme.my.salesforce-scrt.com` |
226
+ | `https://acme.my.salesforce.com/lightning/setup/home` | `acme.my.salesforce-scrt.com` |
227
+ | `acme` | `acme.my.salesforce-scrt.com` |
228
+ | `acme--dev.sandbox.my.salesforce.com` | `acme--dev.sandbox.my.salesforce-scrt.com` |
229
+ | `acme.my.salesforce-scrt.com` | used as given |
230
+
231
+ Hosts with no telephony equivalent are refused with exit 2 rather than turned into something that fails at DNS — a Lightning host like `acme.lightning.force.com`, an Experience Cloud one, or `login.salesforce.com`.
232
+
233
+ Salesforce authenticates the request, then rejects it on schema grounds before writing anything:
234
+
235
+ ```
236
+ HTTP/1.1 400 Bad Request
237
+ instance value ("sf-jwt-probe") not found in enum (possible values: ["WebRTC","PSTN"])
238
+ ```
239
+
240
+ A 400 is therefore the passing result, and it passes precisely because the request failed. A bad token gets a 401 instead, and that is the only outcome that exits non-zero — a 404 says something about `--my-domain`, a network failure says nothing at all, and neither makes a correctly minted token worthless. ADR-0006 covers the reasoning and its limits.
241
+
242
+ No `VoiceCall` is created. If one ever is, the command says so loudly and prints the record ID to delete, because that would be a defect rather than a verdict.
243
+
244
+ The token presented is the same one printed, so the verdict is about the credential you actually receive. With `--jti`, that one `jti` is also reused as the probe's `vendorCallKey`, which lets you match the request to the token in Salesforce's logs. Salesforce documents `jti` as replay-protected — if it enforces that strictly, the probe counts as the token's first use; that has not been tested, and `--jti` is off by default.
245
+
246
+ Pass `--no-probe` to skip all of it and mint offline. You have to choose one or the other: without either flag the command exits 2, so an unverified token is never mistaken for a verified one.
247
+
248
+ ```bash
249
+ sf-jwt scv-auth-token --iss 00DHs0000012345 --sub cc1 --key server.key --no-probe
250
+ ```
251
+
252
+ Every claim shared with the OAuth flow means something different:
253
+
254
+ | Claim | `jwt-bearer-flow` | `scv-auth-token` |
255
+ | ----- | -------------------------------- | -------------------- |
256
+ | `iss` | External Client App consumer key | Salesforce org ID |
257
+ | `sub` | Salesforce username | Call Center API Name |
258
+ | `aud` | login host | not sent |
259
+ | `iat` | not sent | required |
260
+ | `jti` | not sent | optional, `--jti` |
261
+
262
+ Since `--iss` and `--sub` are shared flags, `scv-auth-token` refuses an `--iss` that isn't shaped like an org ID and a `--sub` containing `@` — the two mistakes you'd make with `SF_JWT_ISS` still exported from validation work. Those are the only checks. A wrong-but-plausible Call Center name is left for Salesforce to reject, because nothing here can ask it. Flags belonging to the other subcommand, like `--aud`, are refused outright rather than silently ignored; inapplicable `SF_JWT_*` variables are ignored, so an exported audience doesn't break minting.
263
+
264
+ ### The key lives in AWS
265
+
266
+ Salesforce provisions the private key into AWS Systems Manager Parameter Store, and into Secrets Manager for Amazon Connect contact centers from Winter '26. The tool takes no AWS dependency (ADR-0002) — pipe it in, which also keeps the key off disk:
267
+
268
+ ```bash
269
+ aws ssm get-parameter --name /scv/private-key --with-decryption \
270
+ --query Parameter.Value --output text |
271
+ sf-jwt scv-auth-token --iss 00DHs0000012345 --sub cc1 \
272
+ --my-domain your-org.my.salesforce.com --key -
273
+ ```
274
+
275
+ ```bash
276
+ aws secretsmanager get-secret-value --secret-id scv/private-key \
277
+ --query SecretString --output text |
278
+ sf-jwt scv-auth-token --iss 00DHs0000012345 --sub cc1 \
279
+ --my-domain your-org.my.salesforce.com --key -
280
+ ```
281
+
282
+ ### Why `--exp` defaults to an hour here
283
+
284
+ `jwt-bearer-flow` defaults to 86400 because ADR-0003 established that Salesforce accepts a day-long assertion. This API documents a 24-hour ceiling, which makes 86400 exactly the boundary value — and Salesforce's own example claim set sits 13 seconds under it. This subcommand also makes no request, so it cannot measure the clock drift that would push a boundary value over.
285
+
286
+ A minted token is never narrowed by an exchange step either: it is live for its full lifetime. Hence 3600. No ceiling is enforced, so `--exp 86400` works if you want it and Salesforce judges.
287
+
288
+ ### Why `--jti` is opt-in
289
+
290
+ Salesforce validates that a `jti` it receives hasn't been seen before, which prevents replay attacks. But an SCV token is designed to be reused across many requests for its whole lifetime, and read literally that check would reject every request after the first. The documentation doesn't resolve the contradiction and we haven't tested it against a real org, so `jti` is omitted unless you ask. If you do pass `--jti`, treat the token as single-use until you've confirmed otherwise.
291
+
292
+ ## Signing and reading arbitrary tokens
293
+
294
+ `encode` and `decode` know nothing about Salesforce. Neither makes a request, neither reads meaning into a claim, and neither produces a verdict.
295
+
296
+ ```bash
297
+ sf-jwt encode --payload '{"iss":"me","sub":"you"}' --exp 300 --key server.key
298
+ sf-jwt decode --jwt "$TOKEN" --verify server.crt
299
+ ```
300
+
301
+ Every input accepts its content directly, a path to read it from, or `-` for stdin. A value starting with `{` is read as JSON and anything else as a path; a value whose first segment decodes to a JWT header is read as a token, so `a.b.jwt` is still a filename. Only one input per invocation may be `-`, since there is only one stdin, and two asking for it is an error rather than a hang.
302
+
303
+ ### encode
304
+
305
+ | Flag | Meaning | Default |
306
+ | ------------------ | --------------------------------------- | ----------------------- |
307
+ | `--payload <json>` | The claims to sign | required |
308
+ | `--header <json>` | The header to sign | `{"alg":…,"typ":"JWT"}` |
309
+ | `--key <path>` | Private key PEM; `-` reads stdin | unsigned when absent |
310
+ | `--exp <seconds>` | Set `exp` to now plus this many seconds | — |
311
+ | `--exp-at <epoch>` | Set `exp` absolutely; overrides `--exp` | — |
312
+ | `--json` | `{token, header, claims}` on stdout | off |
313
+
314
+ The key decides the algorithm and the header only names it. Leave `alg` out and it is filled in from the key — `RS256` for RSA, `ES256`/`ES384`/`ES512` by curve, `EdDSA` for Ed25519 and Ed448, `PS256` for a key that declares itself RSA-PSS. Name an algorithm the key cannot perform and it is refused with exit 2, rather than attempted: a P-384 key will happily produce bytes for an `ES256` request, and nothing on earth would accept the result.
315
+
316
+ Supported: `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`, `EdDSA`. HMAC is deliberately absent — every algorithm here is one where the signing key cannot also verify, which is what keeps `--key` and `--verify` honest about which is which. ADR-0007 records the boundary.
317
+
318
+ Apart from `exp`, nothing is added to your payload. `--exp` and `--exp-at` overwrite an `exp` already in it, and unlike everywhere else they are read from the flag only: an exported `SF_JWT_EXP` cannot quietly edit signed material.
319
+
320
+ With no `--key` and no `SF_JWT_KEY_FILE` or `SF_JWT_PRIVATE_KEY`, the payload is encoded but not signed:
321
+
322
+ ```
323
+ eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJtZSJ9.
324
+ warning: This token is not signed: no --key, and no SF_JWT_KEY_FILE or SF_JWT_PRIVATE_KEY set.
325
+ ```
326
+
327
+ `alg` becomes `none` and the third segment is empty, so the token is a valid unsecured JWT that describes itself accurately and every correctly configured verifier refuses it. Read ADR-0008 before relying on this in a script: if a key variable goes missing in CI, this is what you get instead of a failure.
328
+
329
+ ### decode
330
+
331
+ | Flag | Meaning | Default |
332
+ | ----------------- | --------------------------------------------------------- | -------- |
333
+ | `--jwt <token>` | The token to read; `-` reads stdin | required |
334
+ | `--verify <path>` | Certificate, public key, or private key; `-` reads stdin | no check |
335
+ | `--json` | Fold the key match and the times into the stdout document | off |
336
+
337
+ The decoded document goes to stdout as JSON with no flag needed, so `sf-jwt decode --jwt "$TOKEN" | jq .claims.exp` works as it stands, and the report goes to stderr:
338
+
339
+ ```
340
+ VALID key match — signature verifies against server.crt (certificate, RS256)
341
+ iat: 2026-08-08T16:40:52.000Z (2m ago)
342
+ exp: 2026-08-09T16:40:52.000Z (in 1d)
343
+ ```
344
+
345
+ `--verify` takes any of three things, because all three answer the same question and people have different ones to hand: the X.509 certificate uploaded to Salesforce, a bare public key, or the private key itself, whose public half is derived. That last form is the useful one when Salesforce has told you `invalid_grant` / `invalid signature` — it answers "does the key I signed with match this token?" locally, with nothing to ask and no round trip.
346
+
347
+ The result is a **key match**, not a verdict. It is labelled `VALID` or `INVALID` rather than `PASS` or `FAIL` so a line pasted into a ticket can't be read as something Salesforce said, and it establishes exactly one thing: that the holder of that key produced this token.
348
+
349
+ It judges the signature and never the clock. `exp`, `nbf` and `iat` are reported every time, with or without `--verify`, and never affect the result — so a sound signature on a token that expired last year is `VALID` and exits 0, with the expiry stated plainly beneath. That will look wrong, and it is deliberate: merging the two is how a stale clock gets reported as a bad key, which is the misdiagnosis this whole tool exists to correct. ADR-0009 explains it.
350
+
351
+ A token that won't decode exits 2, not 1 — the tool couldn't proceed, which is a different thing from an answer about a credential. An algorithm it cannot verify, such as `HS256`, exits 2 for the same reason.
352
+
353
+ ## Environment variables
354
+
355
+ Every argument has an equivalent, and flags take precedence:
356
+
357
+ `SF_JWT_ISS`, `SF_JWT_SUB`, `SF_JWT_AUD`, `SF_JWT_SANDBOX`, `SF_JWT_EXP`, `SF_JWT_EXP_AT`, `SF_JWT_TOKEN_URL`, `SF_JWT_TIMEOUT`, `SF_JWT_JTI`, `SF_JWT_MY_DOMAIN`, `SF_JWT_NO_PROBE`.
358
+
359
+ For the key: `SF_JWT_KEY_FILE` holds a path, `SF_JWT_PRIVATE_KEY` holds the PEM contents directly, and `SF_JWT_KEY_PASSPHRASE` unlocks an encrypted key. Encrypted keys also prompt interactively when no passphrase is set.
360
+
361
+ ## Exit codes
362
+
363
+ | Code | Meaning |
364
+ | ---- | -------------------------------------------------------- |
365
+ | `0` | It worked: a token was issued, minted, signed or decoded |
366
+ | `1` | The credential was refused |
367
+ | `2` | Usage error — bad arguments, unreadable key, not a JWT |
368
+ | `3` | Transport failure — DNS, TLS, timeout |
369
+
370
+ Code `1` and code `2` are kept apart on purpose: a typo'd flag should never look like a broken app. The same line divides "the credential was refused" from "the tool could not proceed" — an undecodable token is `2`, because failing to read something is not a judgement about it.
371
+
372
+ Which subcommand reaches what:
373
+
374
+ | Subcommand | `1` means | `3`? |
375
+ | ----------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
376
+ | `jwt-bearer-flow` | Salesforce rejected the assertion | yes |
377
+ | `scv-auth-token` | The probe got a 401 | never — a correctly minted token isn't invalidated by an unreachable network, so a transport failure warns on stderr and exits `0` |
378
+ | `encode` | never returned | never — makes no request |
379
+ | `decode` | The key did not match, or nothing signed it | never — makes no request |
380
+
381
+ ## Secrets in verbose output
382
+
383
+ An assertion is a bearer credential until it expires, and so is the access token. Under `-v` the trace includes full HTTP headers and bodies, so the assertion, the `authorization` header, `set-cookie`, and any `access_token` print in full when stderr is an interactive terminal and are truncated to a short prefix when it isn't or when `CI` is set.
384
+
385
+ This matters more than usual given the 86400-second default, which leaves a printed assertion exchangeable for a full day.
386
+
387
+ ## Colour
388
+
389
+ At a terminal, stderr is dimmed and the verdict label is coloured by outcome — green for a token Salesforce accepted, red for one it refused, yellow for inconclusive. The dimming is what makes the token on stdout stand out when both streams share a screen.
390
+
391
+ stdout is never coloured, so a captured token and `--json` output stay byte-clean. Redirect or pipe stderr and the escapes disappear too; set `NO_COLOR` to switch them off at a terminal, or `FORCE_COLOR` to keep them through a pipe.
392
+
393
+ ## Which stream carries what
394
+
395
+ Every subcommand follows one rule: **stdout is the product, stderr is everything else.**
396
+
397
+ | Channel | Carries |
398
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
399
+ | stdout | The token — or, for `decode`, the decoded document; or the whole document under `--json`. Never coloured, redacted or truncated. |
400
+ | stderr | The verdict or key match, the identity or probe result, the version stamp, warnings, errors, and the `-v` trace. |
401
+
402
+ So `TOKEN=$(sf-jwt ...)` works for any subcommand that produces one, and the report still reaches your screen while it does.
403
+
404
+ stderr is a trace: it may end up in a CI log or a pasted bug report, and nobody asked for its contents, which is why credentials appearing there are truncated when it isn't a terminal. stdout is the opposite — it carries only what you asked for, so it is never redacted regardless of where it points. ADR-0005 records the distinction. Both tokens are live bearer credentials for their full lifetime, so shell history and CI logs are yours to manage.
405
+
406
+ ## Development
407
+
408
+ ```bash
409
+ npm test # node:test suite, no test framework installed
410
+ npm run lint # ESLint
411
+ npm run format:check # Prettier
412
+ npm run build # esbuild → dist/sf-jwt.js (minified, single file)
413
+ ```
414
+
415
+ Publishing runs `build` via `prepublishOnly`, and the tarball ships only `dist/`, `README.md`, and `LICENSE`.
416
+
417
+ Within the source repository, `CONTEXT.md` holds the domain vocabulary and `docs/adr/` records the decisions that look wrong without their reasoning — read ADR-0002 before adding a dependency, ADR-0003 before touching the `--exp` default, ADR-0005 before changing what a subcommand writes to stdout, ADR-0007 before adding an algorithm, ADR-0008 before making an absent key an error, and ADR-0009 before making `--verify` reject an expired token.
package/dist/sf-jwt.js ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ import{parseArgs as At}from"node:util";import{constants as Ge,sign as ze,verify as Ze}from"node:crypto";var h=class extends Error{constructor(t,{hint:n}={}){super(t),this.name="UsageError",this.exitCode=2,this.hint=n}},J=class extends Error{constructor(t,{cause:n,url:o}={}){super(t,{cause:n}),this.name="TransportError",this.exitCode=3,this.url=o}};var{RSA_PKCS1_PADDING:G,RSA_PKCS1_PSS_PADDING:z}=Ge,A="none",he={RS256:{family:"rsa",hash:"sha256",options:{padding:G}},RS384:{family:"rsa",hash:"sha384",options:{padding:G}},RS512:{family:"rsa",hash:"sha512",options:{padding:G}},PS256:{family:"rsa",hash:"sha256",options:{padding:z,saltLength:32}},PS384:{family:"rsa",hash:"sha384",options:{padding:z,saltLength:48}},PS512:{family:"rsa",hash:"sha512",options:{padding:z,saltLength:64}},ES256:{family:"ec",curve:"prime256v1",hash:"sha256",options:{dsaEncoding:"ieee-p1363"}},ES384:{family:"ec",curve:"secp384r1",hash:"sha384",options:{dsaEncoding:"ieee-p1363"}},ES512:{family:"ec",curve:"secp521r1",hash:"sha512",options:{dsaEncoding:"ieee-p1363"}},EdDSA:{family:"edwards",hash:null,options:{}}},C=Object.keys(he),Qe=new Set(["rsa","rsa-pss"]),fe=new Set(["ed25519","ed448"]),et={prime256v1:"ES256",secp384r1:"ES384",secp521r1:"ES512"},ue=e=>Buffer.from(e).toString("base64url");function tt(e){let t=e.asymmetricKeyType;return Qe.has(t)?"rsa":t==="ec"?"ec":fe.has(t)?"edwards":null}function Z(e){let t=e.asymmetricKeyType;if(t==="rsa")return"RS256";if(t==="rsa-pss")return"PS256";if(fe.has(t))return"EdDSA";if(t==="ec"){let n=e.asymmetricKeyDetails?.namedCurve,o=et[n];if(o)return o;throw new h(`No JWT algorithm exists for the EC curve ${n??"this key uses"}.`,{hint:`JWS defines only P-256, P-384 and P-521. Supported: ${C.join(", ")}.`})}throw new h(`A ${t??"key of an unrecognised type"} cannot sign a JWT.`,{hint:"Use an RSA, EC (P-256/P-384/P-521), Ed25519 or Ed448 private key."})}function R(e,t){if(t===A)throw new h('alg "none" cannot be requested.',{hint:"Omit --key and every SF_JWT key variable to produce an unsigned token; encode writes the none itself."});let n=he[t];if(!n){let r=/^HS(256|384|512)$/.test(t)?"sf-jwt signs asymmetrically only, so that a signing key can never verify. An HMAC secret does both.":`Supported: ${C.join(", ")}.`;throw new h(`Unsupported algorithm "${t}".`,{hint:r})}if(tt(e)!==n.family)throw new h(`The header asks for ${t}, but the key is ${e.asymmetricKeyType??"of an unrecognised type"}.`,{hint:`${t} needs ${{rsa:"an RSA",ec:"an EC",edwards:"an Ed25519 or Ed448"}[n.family]} key.`});if(n.curve){let r=e.asymmetricKeyDetails?.namedCurve;if(r!==n.curve)throw new h(`The header asks for ${t}, but the key's curve is ${r??"unknown"}.`,{hint:`${t} is defined only over ${n.curve}.`})}return n}var me=(e,t)=>`${ue(JSON.stringify(e))}.${ue(JSON.stringify(t))}`;function U(e,t,n){let o=R(n,e.alg),r=me(e,t),s=ze(o.hash,Buffer.from(r),{key:n,...o.options});return`${r}.${s.toString("base64url")}`}var ye=(e,t)=>`${me(e,t)}.`;function pe(e,t){let n;try{n=Buffer.from(e,"base64url").toString("utf8")}catch{throw new h(`The token's ${t} is not valid base64url.`)}let o;try{o=JSON.parse(n)}catch{throw new h(`The token's ${t} does not contain JSON.`,{hint:"A JWT carries base64url-encoded JSON in its first two segments."})}if(typeof o!="object"||o===null||Array.isArray(o))throw new h(`The token's ${t} is not a JSON object.`);return o}function Q(e){let n=e.trim().split(".");if(n.length!==3)throw new h(`A JWT has three dot-separated segments; this has ${n.length}.`,{hint:n.length===5?"Five segments means a JWE. This tool reads signed tokens, not encrypted ones.":void 0});let[o,r,s]=n,a=pe(o,"header");return{header:a,claims:pe(r,"payload"),signature:s,signingInput:`${o}.${r}`,signed:s!==""&&a.alg!==A}}var nt=1e11,ot=e=>{try{return new Date(e*1e3).toISOString()}catch{return null}};function ge(e,t=Date.now()){let n=Math.floor(t/1e3),o={};for(let r of["iat","nbf","exp"]){let s=e[r];typeof s!="number"||!Number.isFinite(s)||(o[r]={value:s,at:ot(s),deltaSeconds:s-n,looksLikeMilliseconds:s>=nt})}return{...o,expired:o.exp?o.exp.deltaSeconds<=0:null,notYetValid:o.nbf?o.nbf.deltaSeconds>0:null}}function Se({header:e,signingInput:t,signature:n},o){let r=R(o,e.alg);return Ze(r.hash,Buffer.from(t),{key:o,...r.options},Buffer.from(n,"base64url"))}var rt={alg:"RS256",typ:"JWT"};function we({iss:e,sub:t,aud:n,expiresInSeconds:o,expiresAt:r,now:s=Date.now()}){let a=Math.floor(s/1e3),i=r??a+o;return t?{iss:e,sub:t,aud:n,exp:i}:{iss:e,aud:n,exp:i}}function ee(e,t){return U(rt,e,t)}var st=[{id:"assertion-expired",error:"invalid_grant",match:"expired authorization code",cause:"Salesforce considered the assertion already expired when it arrived. Despite the wording, this is not about an authorization code.",remediation:["The exp claim must be in the future when Salesforce receives it. Check the clock drift reported above \u2014 a machine running fast enough will mint assertions that are already stale.","Confirm exp is in seconds, not milliseconds. A millisecond value is read as a date far in the past or future.","If you passed --exp-at, check the epoch you supplied is the time you meant."]},{id:"invalid-signature",error:"invalid_grant",match:"invalid signature",cause:"The signature did not verify against the certificate registered on the app.",remediation:['The private key you signed with does not match the uploaded certificate. Re-upload the .crt generated from this exact key under Flow Enablement (Connected Apps: "Use digital signatures").',"Confirm you are pointed at the org that holds this app \u2014 a sandbox copy carries its own certificate."]},{id:"user-not-approved",error:"invalid_grant",match:"user hasn\u2019t approved this consumer",cause:"The Subject User has never authorized this app, and the app is not configured to pre-authorize users.",remediation:['External Client App Manager \u2192 your app \u2192 Policies \u2192 Edit \u2192 OAuth Policies \u2192 set Permitted Users to "Admin approved users are pre-authorized". (Connected Apps: Manage \u2192 Edit Policies.)',"Then assign the Subject User\u2019s Profile or a Permission Set under App Policies, otherwise pre-authorization applies to nobody."]},{id:"invalid-audience",error:"invalid_grant",match:"invalid audience",cause:"The aud claim does not match the endpoint that received the assertion.",remediation:["Production and Developer Edition orgs use https://login.salesforce.com; sandboxes use https://test.salesforce.com (or pass --sandbox).","Experience Cloud sites need the full site URL as aud, including its path."]},{id:"ip-restricted",error:"invalid_grant",match:"ip restricted",cause:"The app or the user\u2019s profile blocked the calling IP address.",remediation:['External Client App Manager \u2192 your app \u2192 Policies \u2192 Edit \u2192 OAuth Policies \u2192 IP Relaxation \u2192 "Relax IP restrictions". (Connected Apps: Manage \u2192 Edit Policies.)',"Alternatively add this machine\u2019s address to the profile\u2019s Login IP Ranges."]},{id:"inactive-user",error:"invalid_grant",match:"inactive user",cause:"The Subject User exists but is deactivated in this org.",remediation:["Reactivate the user, or point --sub at an active integration user."]},{id:"user-locked-out",error:"invalid_grant",match:"user is locked out",cause:"The Subject User is locked out of the org.",remediation:["Unlock the user in Setup \u2192 Users, then retry."]},{id:"invalid-assertion",error:"invalid_grant",match:"invalid assertion",cause:"Salesforce could not parse the assertion at all.",remediation:["The iss, aud, and exp claims must all be present, and exp must be a number.",'The header must be {"alg":"RS256"}; Salesforce rejects other algorithms outright.']},{id:"invalid-client-id",error:"invalid_client_id",cause:"The iss claim is not a consumer key this org recognises.",remediation:["Copy the consumer key from External Client App Manager \u2192 your app \u2192 Settings \u2192 OAuth Settings, and check for truncation \u2014 they are long. (Connected Apps: App Manager \u2192 View.)","Confirm the org matches: a sandbox app has a different consumer key from its production original.","A newly created or modified app can take up to 10 minutes to become usable."]},{id:"invalid-app-access",error:"invalid_app_access",cause:"The app exists, but this user is not permitted to use it.",remediation:["Assign the Subject User\u2019s Profile or a Permission Set to the app under Policies \u2192 App Policies. (Connected Apps: Manage \u2192 Profiles / Permission Sets.)"]},{id:"app-not-found-no-subject",error:"app_not_found",when:e=>e!==null&&!("sub"in e),cause:"The assertion named no subject. Despite the wording, the app is fine \u2014 Salesforce returns this when it cannot resolve who the token would be for.",remediation:["Pass --sub with the username of the Salesforce user the token should act as.","Salesforce documents the subject as an Experience Cloud concern, but a standard org rejects an assertion without one (ADR-0004), so supply it everywhere."]},{id:"app-not-found",error:"app_not_found",cause:"Salesforce could not resolve an app for this assertion.",remediation:["Confirm the consumer key came from this org \u2014 a sandbox app has a different key from its production original.","A newly created or modified app can take up to 10 minutes to become usable."]},{id:"inactive-org",error:"inactive_org",cause:"The org itself is inactive, expired, or locked.",remediation:["Check whether the org (often a trial or scratch org) has expired."]},{id:"unsupported-grant-type",error:"unsupported_grant_type",cause:"The org or app does not permit the JWT bearer grant.",remediation:['External Client App Manager \u2192 your app \u2192 Settings \u2192 OAuth \u2192 Flow Enablement \u2192 tick "Enable JWT Bearer Flow" and upload the certificate. (Connected Apps: tick "Use digital signatures".)',"Confirm the JWT bearer flow is among the app\u2019s permitted flows."]},{id:"invalid-grant-generic",error:"invalid_grant",cause:"Salesforce rejected the assertion without a description this tool recognises.",remediation:["The usual suspects: an expiry already in the past, a key that does not match the uploaded certificate, or a user who is not pre-authorized on the app."]}],W=e=>(e??"").toLowerCase().replaceAll("\u2019","'");function Te({error:e,errorDescription:t}={},n=null){let o=W(e),r=W(t);return st.find(s=>W(s.error)===o&&(!s.match||r.includes(W(s.match)))&&(!s.when||s.when(n)))??null}import{readFile as it}from"node:fs/promises";var N="-";async function M(e){let t=[];for await(let n of e)t.push(n);return Buffer.concat(t).toString("utf8")}function at(e){let t=e.trim().split(".");if(t.length!==3||!/^[A-Za-z0-9_-]+$/.test(t[0]))return!1;try{let n=JSON.parse(Buffer.from(t[0],"base64url").toString("utf8"));return typeof n=="object"&&n!==null&&!Array.isArray(n)}catch{return!1}}var ct=e=>/^\s*\{/.test(e);function dt(e,t){return e===N?"stdin":t==="json"&&ct(e)||t==="token"&&at(e)?"inline":"path"}async function L(e,{flag:t,kind:n,stdin:o=process.stdin}){let r=dt(e,n);if(r==="inline")return{text:e,source:"inline"};if(r==="stdin")return{text:await M(o),source:"stdin"};try{return{text:await it(e,"utf8"),source:e}}catch(s){throw new h(`Could not read --${t} at ${e}: ${s.code??s.message}`,{hint:n==="json"?"A value starting with { is read as JSON; anything else is read as a path.":"A value is read as a token when its first segment decodes to a JWT header, and as a path otherwise \u2014 so if you meant a token, that token is malformed."})}}function te(e,t){let n;try{n=JSON.parse(e)}catch(o){throw new h(`--${t} is not valid JSON.`,{hint:o.message})}if(typeof n!="object"||n===null||Array.isArray(n))throw new h(`--${t} must be a JSON object, got ${Array.isArray(n)?"an array":typeof n}.`);return n}function ne(e,t){let n=t.filter(o=>e[o]===N);if(n.length>1)throw new h(`--${n[0]} and --${n[1]} both asked to read stdin.`,{hint:"Only one input can come from a pipe. Put the other in a file, or pass it inline."})}import{createPrivateKey as _e,createPublicKey as ke}from"node:crypto";import{readFile as oe}from"node:fs/promises";import{createInterface as lt}from"node:readline";var be=2048,ut=["BEGIN ENCRYPTED PRIVATE KEY","Proc-Type: 4,ENCRYPTED"],pt={prime256v1:"P-256",secp384r1:"P-384",secp521r1:"P-521"};function Ee(e){return ut.some(t=>e.includes(t))}function $e(e,{input:t=process.stdin,output:n=process.stderr}={}){return new Promise((o,r)=>{if(!t.isTTY){r(new h("The private key is passphrase-protected and no passphrase was supplied.",{hint:"Set SF_JWT_KEY_PASSPHRASE, or run from an interactive terminal to be prompted."}));return}let s=lt({input:t,output:n,terminal:!0}),a=!1;s._writeToOutput=i=>{a||n.write(i)},s.question(e,i=>{s.close(),n.write(`
3
+ `),o(i)}),a=!0})}async function ht({keyPath:e,keyFileEnv:t,inlineKeyEnv:n,stdin:o}){if(e===N)return{pem:await M(o),source:"stdin"};if(e)try{return{pem:await oe(e,"utf8"),source:e}}catch(r){throw new h(`Could not read the private key at ${e}: ${r.code??r.message}`)}if(t)try{return{pem:await oe(t,"utf8"),source:`${t} (SF_JWT_KEY_FILE)`}}catch(r){throw new h(`Could not read SF_JWT_KEY_FILE at ${t}: ${r.code??r.message}`)}if(n)return{pem:n,source:"SF_JWT_PRIVATE_KEY"};throw new h("No private key supplied.",{hint:"Pass --key <path>, set SF_JWT_KEY_FILE or SF_JWT_PRIVATE_KEY, or pipe a PEM with --key -"})}function K(e){let t=e.asymmetricKeyDetails??{};return{type:e.asymmetricKeyType??null,bits:t.modulusLength??null,curve:t.namedCurve??null}}function D({type:e,bits:t,curve:n}){return e==="rsa"||e==="rsa-pss"?`${e==="rsa-pss"?"RSA-PSS":"RSA"} ${t}-bit`:e==="ec"?`EC ${pt[n]??n}`:e==="ed25519"?"Ed25519":e==="ed448"?"Ed448":e??"of an unrecognised type"}function ft(e){let t=e.asymmetricKeyType;if(t!=="rsa"&&t!=="rsa-pss")throw new h(`The private key is ${t??"of an unrecognised type"}, but RS256 requires RSA.`,{hint:"Generate one with: openssl genrsa -out server.key 2048"});let n=e.asymmetricKeyDetails?.modulusLength??0;if(n<be)throw new h(`The private key is ${n}-bit; Salesforce requires at least ${be}.`);return{type:t,bits:n}}async function Y({keyPath:e,keyFileEnv:t,inlineKeyEnv:n,passphraseEnv:o,stdin:r=process.stdin,prompt:s=$e,assertUsable:a=ft}={}){let{pem:i,source:d}=await ht({keyPath:e,keyFileEnv:t,inlineKeyEnv:n,stdin:r}),u=Ee(i),c=u?o??await s("Private key passphrase: "):void 0,p;try{p=_e(c?{key:i,passphrase:c}:i)}catch(l){let m=u?"the passphrase is wrong or the key is malformed":"it is not a readable PEM private key (an encrypted key needs a passphrase)";throw new h(`Could not load the private key from ${d} \u2014 ${m}.`,{hint:l.message})}return a(p),{keyObject:p,source:d,encrypted:u,...K(p)}}async function ve({path:e,passphraseEnv:t,stdin:n=process.stdin,prompt:o=$e}={}){let r,s;if(e===N)({pem:r,source:s}={pem:await M(n),source:"stdin"});else try{r=await oe(e,"utf8"),s=e}catch(d){throw new h(`Could not read --verify at ${e}: ${d.code??d.message}`)}let a=r.includes("BEGIN CERTIFICATE"),i=r.includes("PRIVATE KEY");try{if(i){let d=Ee(r)?t??await o("Private key passphrase: "):void 0,u=_e(d?{key:r,passphrase:d}:r);return{keyObject:ke(u),source:s,kind:"private key"}}return{keyObject:ke(r),source:s,kind:a?"certificate":"public key"}}catch(d){throw new h(`Could not read verification material from ${s}.`,{hint:`Expected a PEM X.509 certificate, public key, or private key. ${d.message}`})}}var mt=new Set(["authorization","cookie","set-cookie"]),yt=new Set(["access_token","refresh_token","id_token"]),gt=new Set(["assertion","client_secret"]),P={reset:"\x1B[0m",dim:"\x1B[2m",green:"\x1B[32m",red:"\x1B[31m",yellow:"\x1B[33m",alarm:"\x1B[1;41;97m"};function re(e=process.stderr,t=process.env){return t.NO_COLOR?!1:t.FORCE_COLOR?!0:!!e.isTTY&&t.TERM!=="dumb"}function se(e){let t=n=>o=>e&&o?`${n}${o}${P.reset}`:o;return{enabled:e,dim:t(P.dim),green:t(P.green),red:t(P.red),yellow:t(P.yellow),alarm:t(P.alarm)}}function St(e=process.stderr,t=process.env){return!!e.isTTY&&!t.CI}function B(e,t){return typeof e!="string"||e.length===0?"(empty)":t?e:`${e.slice(0,12)}\u2026 [${e.length} chars, redacted \u2014 not a TTY]`}function xe(e,t){return Object.entries(e??{}).map(([n,o])=>{let r=mt.has(n.toLowerCase())?B(o,t):o;return`${n}: ${r}`})}function wt(e,t){return e?[...new URLSearchParams(e)].map(([n,o])=>{let r=gt.has(n)?B(o,t):o;return`${n}=${r}`}):["(no body)"]}function Ae(e,t){if(!e)return["(empty body)"];let n;try{n=JSON.parse(e)}catch{return e.split(`
4
+ `)}if(typeof n!="object"||n===null||Array.isArray(n))return JSON.stringify(n,null,2).split(`
5
+ `);let o=Object.fromEntries(Object.entries(n).map(([r,s])=>[r,yt.has(r)&&typeof s=="string"?B(s,t):s]));return JSON.stringify(o,null,2).split(`
6
+ `)}var Tt=e=>Math.min(100,Math.max(56,e??76));function O({verbose:e=!1,stream:t=process.stderr,env:n=process.env,showSecrets:o,colors:r}={}){let s=o??St(t,n),a=se(r??re(t,n)),i=Tt(t.columns),d=l=>t.write(`${a.dim(l)}
7
+ `),u=l=>{e&&d(l)},c=l=>{if(!e)return;d("");let m=`\u2500\u2500 ${l} `;d(m+"\u2500".repeat(Math.max(3,i-m.length)))},p=l=>{if(!(!e||l.length===0)){d("");for(let m of l)d(` ${m}`)}};return{verbose:e,printSecrets:s,style:a,section:c,detail(l,m){u(` ${`${l}:`.padEnd(14)}${m}`)},secret(l,m){u(` ${`${l}:`.padEnd(14)}${B(m,s)}`)},warn(l){t.write(`${a.yellow(` warning: ${l}`)}
8
+ `)},httpRequest({method:l,url:m,headers:y,body:g},w="HTTP"){if(e){c(`${w} request`),d(` ${l} ${m}`);for(let S of xe(y,s))d(` ${S}`);if(g){let S=String(y?.["content-type"]??"").includes("json");p(S?Ae(g,s):wt(g,s))}}},httpResponse({status:l,statusText:m,headers:y,rawBody:g,elapsedMs:w},S="HTTP"){if(e){c(`${S} response`),d(` HTTP ${l}${m?` ${m}`:""} \u2014 ${w}ms`);for(let f of xe(y,s))d(` ${f}`);p(Ae(g,s))}}}}var kt="urn:ietf:params:oauth:grant-type:jwt-bearer";function Ie(e){return`${e.replace(/\/+$/,"")}/services/oauth2/token`}function Pe(e){return`${e.replace(/\/+$/,"")}/services/oauth2/userinfo`}function je(e,t=Date.now()){if(!e)return null;let n=Date.parse(e);return Number.isNaN(n)?null:Math.round((t-n)/1e3)}async function ie(e,t,{timeoutMs:n,fetchImpl:o,now:r}){let s=performance.now(),a=r(),i;try{i=await o(e,{...t,signal:AbortSignal.timeout(n)})}catch(p){let l=p.name==="TimeoutError"?`no response within ${n}ms`:p.message;throw new J(`Could not reach ${e} \u2014 ${l}.`,{cause:p,url:e})}let d=r(),u=await i.text(),c;try{c=JSON.parse(u)}catch{c=null}return{request:{method:t.method,url:e,headers:t.headers,body:t.body},ok:i.ok,status:i.status,statusText:i.statusText,headers:Object.fromEntries(i.headers),body:c,rawBody:u,serverDate:i.headers.get("date"),localMidpointMs:Math.round((a+d)/2),elapsedMs:Math.round(performance.now()-s)}}function Ne({tokenUrl:e,assertion:t,timeoutMs:n,fetchImpl:o=fetch,now:r=Date.now}){let s=new URLSearchParams({grant_type:kt,assertion:t});return ie(e,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded",accept:"application/json"},body:s.toString()},{timeoutMs:n,fetchImpl:o,now:r})}function De({probeUrl:e,token:t,payload:n,timeoutMs:o,fetchImpl:r=fetch,now:s=Date.now}){return ie(e,{method:"POST",headers:{authorization:`Bearer ${t}`,"content-type":"application/json",accept:"application/json"},body:JSON.stringify(n)},{timeoutMs:o,fetchImpl:r,now:s})}function Oe({userInfoUrl:e,accessToken:t,timeoutMs:n,fetchImpl:o=fetch,now:r=Date.now}){return ie(e,{method:"GET",headers:{authorization:`Bearer ${t}`,accept:"application/json"}},{timeoutMs:n,fetchImpl:o,now:r})}import{randomUUID as Fe}from"node:crypto";var bt=/^00D[A-Za-z0-9]{12}([A-Za-z0-9]{3})?$/,ae=".my.salesforce.com",ce=".my.salesforce-scrt.com",_t="/telephony/v1/voiceCalls",Et=/\.(com|net|org|edu|gov|mil|io|uk)$/i,$t="sf-jwt-probe",Je=()=>Fe();function vt(e){let t=e.trim().replace(/^[a-z]+:\/\//i,"").replace(/\/.*$/,"");if(!t)throw new h("--my-domain is empty.");if(t.endsWith(ce))return`https://${t}`;if(t.endsWith(ae))return`https://${t.slice(0,-ae.length)}${ce}`;if(Et.test(t))throw new h(`--my-domain must be a My Domain host, got "${e}".`,{hint:`Pass the {MyDomain}${ae} host, or just the {MyDomain} part alone. Lightning, Experience Cloud and login hosts have no telephony equivalent to derive.`});return`https://${t}${ce}`}var Ce=e=>`${vt(e)}${_t}`;function Re({callCenterApiName:e,now:t=Date.now(),jti:n=null}){return{callCenterApiName:e,callSubtype:$t,from:"+18669483147",initiationMethod:"Inbound",participants:[{participantKey:"4081456688",type:"END_USER"}],startTime:new Date(t).toISOString().replace(/\.\d{3}Z$/,"Z"),to:"+14152988103",vendorCallKey:`sf-jwt:${n??Fe()}`}}function Ue({status:e,body:t}){return e===400?{outcome:"accepted",cause:"Salesforce authenticated the token, then rejected the deliberately invalid probe payload. No record was created."}:e===401?{outcome:"rejected",cause:"Salesforce refused the token.",remediation:["Confirm the certificate on the Call Center record matches the signing key in use.","Confirm --iss is the org ID of the org holding that Call Center, and --sub its API Name.","Check this machine\u2019s clock: iat in the future or exp in the past both read as a bad token."]}:e>=200&&e<300?{outcome:"accepted",created:t?.voiceCallId??null,cause:"Salesforce authenticated the token \u2014 but it accepted the probe payload and created a VoiceCall record, which it should have rejected."}:e===403?{outcome:"inconclusive",cause:"Salesforce answered 403. The token may have authenticated and been denied on permissions; the probe cannot tell those apart."}:e===404?{outcome:"inconclusive",cause:"Salesforce answered 404, which points at --my-domain or at Salesforce Voice not being provisioned on this org, rather than at the token."}:{outcome:"inconclusive",cause:`Salesforce answered ${e}, which says nothing definite about the token.`}}function We({iss:e,sub:t,expiresInSeconds:n,expiresAt:o,jti:r,now:s=Date.now()}){let a=Math.floor(s/1e3),i={iat:a,iss:e,sub:t,exp:o??a+n};return r?{...i,jti:r}:i}function Me({iss:e,sub:t}){if(!e)throw new h("Missing --iss (the Salesforce org ID).",{hint:"An org ID starts with 00D and is 15 or 18 characters."});if(!bt.test(e))throw new h(`--iss must be a Salesforce org ID, got "${e}".`,{hint:"An org ID starts with 00D and is 15 or 18 characters. A Consumer Key is not one \u2014 that belongs to jwt-bearer-flow."});if(!t)throw new h("Missing --sub (the Salesforce CallCenter API Name).",{hint:"This API documents sub as required, and nothing here contacts Salesforce to reject it for you."});if(t.includes("@"))throw new h(`--sub must be a CallCenter API Name, got "${t}".`,{hint:'An API name cannot contain "@". A Salesforce username belongs to jwt-bearer-flow.'})}var I="1.2.0";var Be="https://login.salesforce.com",He="https://test.salesforce.com",Ve=86400,V=1e4,It=30,qe=3600,_="jwt-bearer-flow",k="scv-auth-token",b="encode",v="decode",Le=[_,k,b,v],Pt={iss:{type:"string"},sub:{type:"string"},aud:{type:"string"},exp:{type:"string"},"exp-at":{type:"string"},jti:{type:"boolean"},"my-domain":{type:"string"},"no-probe":{type:"boolean"},key:{type:"string"},header:{type:"string"},payload:{type:"string"},jwt:{type:"string"},verify:{type:"string"},"token-url":{type:"string"},timeout:{type:"string"},sandbox:{type:"boolean"},"skip-userinfo":{type:"boolean"},json:{type:"boolean"},verbose:{type:"boolean",short:"v"},help:{type:"boolean",short:"h"},version:{type:"boolean"}},jt=["help","version","verbose","json"],Ke={[_]:["iss","sub","aud","sandbox","exp","exp-at","key","token-url","timeout","skip-userinfo"],[k]:["iss","sub","exp","exp-at","jti","my-domain","no-probe","key","timeout"],[b]:["header","payload","key","exp","exp-at"],[v]:["jwt","verify"]},Nt=`sf-jwt ${I} \u2014 mint, inspect and validate JSON Web Tokens.
9
+
10
+ Signs and decodes JWTs of any shape, and specialises in the two Salesforce flows
11
+ that consume them, where the org's configuration is what's under test.
12
+
13
+ Usage
14
+ sf-jwt ${_} --iss <consumer-key> --key <path/to/private.key> [--sub <username>] [options]
15
+ sf-jwt ${k} --iss <sf-org-id> --sub <call-center-api-name> --key <path/to/private.key> [options]
16
+ sf-jwt ${b} --payload < json | path | - > [--header < json | path | - >] [--key < path | - >]
17
+ sf-jwt ${v} --jwt < token | path | - > [--verify < path | - >]
18
+
19
+ Subcommands
20
+ ${_} Exchange an assertion for an access token (the default)
21
+ ${k} Mint and verify a Telephony Integration REST API token
22
+ ${b} Sign an arbitrary header and payload
23
+ ${v} Read a token, and optionally check its signature
24
+
25
+ Each prints its product to stdout and its report to stderr. Every subcommand has
26
+ its own help: "sf-jwt ${b} --help", and so on.
27
+
28
+ Everything below belongs to ${_}; the others take different sets.
29
+
30
+ Required
31
+ --iss <key> External Client App consumer key [env: SF_JWT_ISS]
32
+ --key <path> RSA private key PEM; "-" reads stdin [env: SF_JWT_KEY_FILE]
33
+ Key contents may instead go in env: SF_JWT_PRIVATE_KEY
34
+ Passphrase for encrypted keys in env: SF_JWT_KEY_PASSPHRASE
35
+
36
+ Claims
37
+ --sub <username> Salesforce user to act as; optional [env: SF_JWT_SUB]
38
+ here, but orgs reject an assertion
39
+ that omits it
40
+ --aud <url> Audience (default ${Be}) [env: SF_JWT_AUD]
41
+ --sandbox Shorthand for --aud ${He} [env: SF_JWT_SANDBOX]
42
+ --exp <seconds> Seconds from now (default ${Ve}) [env: SF_JWT_EXP]
43
+ --exp-at <epoch> Absolute expiry; overrides --exp [env: SF_JWT_EXP_AT]
44
+
45
+ Request
46
+ --token-url <url> Override the derived token endpoint [env: SF_JWT_TOKEN_URL]
47
+ --timeout <ms> Request timeout (default ${V}) [env: SF_JWT_TIMEOUT]
48
+ --skip-userinfo Stop after the token, skip identity confirmation
49
+
50
+ Output
51
+ -v, --verbose Full trace on stderr, including HTTP headers and bodies
52
+ --json Structured result on stdout, in place of the bare token
53
+ -h, --help This text
54
+ --version Print version
55
+
56
+ The access token goes to stdout alone; the verdict, the identity it resolved to and
57
+ everything else go to stderr, so the token can be captured:
58
+ TOKEN=$(sf-jwt ${_} --iss 3MVG9... --sub svc@acme.com --key server.key)
59
+
60
+ A rejected run has no token to print, so stdout stays empty and the exit code is 1.
61
+
62
+ Exit codes
63
+ 0 Salesforce issued an access token
64
+ 1 Salesforce rejected the assertion (a setup problem)
65
+ 2 Usage error (bad arguments, unreadable key)
66
+ 3 Transport failure (DNS, TLS, timeout)
67
+ `,Dt=`sf-jwt ${I} \u2014 mint a Salesforce Telephony Integration REST API token.
68
+
69
+ Signs an SCV Auth Token, then presents it to the telephony API to confirm Salesforce
70
+ accepts it. The token is itself the bearer credential \u2014 nothing is exchanged for it.
71
+
72
+ The confirmation posts a voice call that cannot be created: one field is deliberately
73
+ invalid, so Salesforce authenticates the request and then rejects it on schema
74
+ grounds without writing anything. HTTP 400 is therefore the passing result, and 401
75
+ means the token was refused.
76
+
77
+ Usage
78
+ sf-jwt ${k} --iss <sf-org-id> --sub <call-center-api-name> --key <path/to/private.key> --my-domain <host> [options]
79
+
80
+ Required
81
+ --iss <org-id> Salesforce org ID, starts with 00D [env: SF_JWT_ISS]
82
+ --sub <name> CallCenter API Name [env: SF_JWT_SUB]
83
+ --key <path> RSA private key PEM; "-" reads stdin [env: SF_JWT_KEY_FILE]
84
+ Key contents may instead go in env: SF_JWT_PRIVATE_KEY
85
+ Passphrase for encrypted keys in env: SF_JWT_KEY_PASSPHRASE
86
+ --my-domain <host> My Domain host to verify against [env: SF_JWT_MY_DOMAIN]
87
+ e.g. acme.my.salesforce.com, or just acme; https:// and a
88
+ trailing path are accepted and ignored. The telephony host
89
+ is derived from it, so pass the org's host, not a Lightning
90
+ or login one. Not required if you pass --no-probe
91
+
92
+ Claims
93
+ --exp <seconds> Seconds from now (default ${qe}) [env: SF_JWT_EXP]
94
+ --exp-at <epoch> Absolute expiry; overrides --exp [env: SF_JWT_EXP_AT]
95
+ --jti Add a unique JWT ID [env: SF_JWT_JTI]
96
+ Salesforce treats a JTI as replay-protected, and the probe
97
+ presents the token once; also used as the probe's
98
+ vendorCallKey, so the two can be correlated
99
+
100
+ Request
101
+ --no-probe Mint without verifying [env: SF_JWT_NO_PROBE]
102
+ --timeout <ms> Probe timeout (default ${V}) [env: SF_JWT_TIMEOUT]
103
+
104
+ Output
105
+ -v, --verbose Full trace on stderr
106
+ --json {token, claims, probe} on stdout, not the bare token
107
+ -h, --help This text
108
+
109
+ The token is this command's output, so it prints to stdout whole and untruncated,
110
+ while the probe verdict goes to stderr and stays out of the way:
111
+ TOKEN=$(sf-jwt ${k} --iss 00D... --sub cc1 --key server.key \\
112
+ --my-domain acme.my.salesforce.com)
113
+
114
+ The signing key normally lives in AWS; piping it keeps it off disk:
115
+ aws ssm get-parameter --name <name> --with-decryption \\
116
+ --query Parameter.Value --output text \\
117
+ | sf-jwt ${k} --iss 00D... --sub cc1 --my-domain acme.my.salesforce.com --key -
118
+
119
+ Exit codes
120
+ 0 Token minted, and either accepted or not conclusively judged
121
+ 1 Salesforce refused the token
122
+ 2 Usage error (bad arguments, unreadable key)
123
+ `,Ot=`sf-jwt ${I} \u2014 sign an arbitrary JWT.
124
+
125
+ Takes the header and payload you give it and signs them. Nothing about the claims is
126
+ checked or assumed: no Salesforce meaning is read into iss or sub, and no claim is added
127
+ except exp, and then only if you ask.
128
+
129
+ Usage
130
+ sf-jwt ${b} --payload < json | path | - > [--header < json | path | - >] [--key < path | - >]
131
+
132
+ Every input takes its content directly, a path to read it from, or "-" for stdin. A value
133
+ starting with { is read as JSON, anything else as a path. Only one input may be "-".
134
+
135
+ Claims
136
+ --payload <json> The claims to sign required
137
+ --header <json> The header to sign default {"alg":\u2026,"typ":"JWT"}
138
+ alg is filled in from the key when you leave it out, and an alg
139
+ the key cannot perform is refused rather than attempted
140
+ --exp <seconds> Set exp to now plus this many seconds; overwrites any exp in
141
+ the payload. Not read from the environment
142
+ --exp-at <epoch> Set exp absolutely; overrides --exp
143
+
144
+ Signing
145
+ --key <path> Private key PEM; "-" reads stdin [env: SF_JWT_KEY_FILE]
146
+ Key contents may instead go in env: SF_JWT_PRIVATE_KEY
147
+ Passphrase for encrypted keys in env: SF_JWT_KEY_PASSPHRASE
148
+
149
+ Signs with ${C.join(", ")}.
150
+ HMAC is deliberately absent: every algorithm here is one where a
151
+ signing key cannot also verify.
152
+
153
+ With no key and no key variable set, the payload is encoded but
154
+ not signed \u2014 alg becomes "none", the signature is empty, and a
155
+ warning goes to stderr.
156
+
157
+ Output
158
+ -v, --verbose Trace on stderr
159
+ --json {token, header, claims} on stdout, not the bare token
160
+ -h, --help This text
161
+
162
+ The token is the product, so it goes to stdout alone:
163
+ TOKEN=$(sf-jwt ${b} --payload '{"iss":"me","exp":1900000000}' --key server.key)
164
+
165
+ Exit codes
166
+ 0 Token produced, signed or otherwise
167
+ 2 Usage error (bad JSON, unreadable key, an alg the key cannot perform)
168
+ `,Ft=`sf-jwt ${I} \u2014 read a JWT, and optionally check its signature.
169
+
170
+ Prints the header and claims as JSON on stdout. With --verify it also reports a key
171
+ match: whether the holder of that key produced this token.
172
+
173
+ Usage
174
+ sf-jwt ${v} --jwt < token | path | - > [--verify < path | - >]
175
+
176
+ Input
177
+ --jwt <token> The token to read required
178
+ Takes the token itself, a path, or "-" for stdin. A value whose
179
+ first segment decodes to a JWT header is read as a token
180
+
181
+ Verification
182
+ --verify <path> Verification material; "-" reads stdin
183
+ Accepts an X.509 certificate (the one uploaded to Salesforce),
184
+ a public key, or a private key \u2014 whose public half is derived,
185
+ which answers "does the key I signed with match this token?"
186
+ [passphrase for an encrypted key: env SF_JWT_KEY_PASSPHRASE]
187
+
188
+ A key match judges the signature and nothing else. exp, nbf and iat are always reported,
189
+ with or without --verify, and never affect the result: a sound signature on a token that
190
+ expired last year is still VALID, and still exits 0. The two are different questions and
191
+ answering them together is how a stale clock gets reported as a bad key.
192
+
193
+ Output
194
+ -v, --verbose Trace on stderr
195
+ --json Fold the key match and the times into the stdout document
196
+ -h, --help This text
197
+
198
+ The decoded document is the product, so it goes to stdout and the report to stderr:
199
+ sf-jwt ${v} --jwt "$TOKEN" | jq .claims.exp
200
+
201
+ Exit codes
202
+ 0 Token decoded; if a key was given, it matched
203
+ 1 The key did not match, or the token carries no signature to match
204
+ 2 Usage error (not a JWT, unreadable material, an alg this tool cannot verify)
205
+ `;function Jt(e){return!Object.entries(e).some(([t,n])=>t.startsWith("SF_JWT_")&&n)}function Ct(e){if(e.length===0)return _;let[t,...n]=e;if(!Le.includes(t))throw new h(`Unknown subcommand "${t}".`,{hint:`Expected one of: ${Le.join(", ")}.`});if(n.length>0)throw new h(`Unexpected argument "${n[0]}".`);return t}function Rt(e,t){let n=new Set([...jt,...Ke[e]]);for(let o of Object.keys(t)){if(n.has(o))continue;let r=Object.entries(Ke).filter(([,s])=>s.includes(o)).map(([s])=>s);throw new h(`--${o} does not apply to ${e}.`,{hint:r.length>0?`It belongs to ${r.join(" and ")}.`:void 0})}}function $(e,t){if(e===void 0)return;let n=Number(e);if(!Number.isInteger(n))throw new h(`--${t} must be an integer, got "${e}".`);return n}function H(e){let t=Math.abs(e),n=o=>String(Number(o.toFixed(1)));return t<60?`${e}s`:t<3600?`${n(e/60)}m`:t<86400?`${n(e/3600)}h`:`${n(e/86400)}d`}function Ut(e,t){let n=e.sandbox||t.SF_JWT_SANDBOX==="true",o=e.aud??t.SF_JWT_AUD??(n?He:Be),r=e.iss??t.SF_JWT_ISS,s=e.sub??t.SF_JWT_SUB;if(!r)throw new h("Missing --iss (the External Client App consumer key).");return{iss:r,sub:s,aud:o,expiresInSeconds:$(e.exp??t.SF_JWT_EXP,"exp")??Ve,expiresAt:$(e["exp-at"]??t.SF_JWT_EXP_AT,"exp-at"),keyPath:e.key,keyFileEnv:t.SF_JWT_KEY_FILE,inlineKeyEnv:t.SF_JWT_PRIVATE_KEY,passphraseEnv:t.SF_JWT_KEY_PASSPHRASE,tokenUrl:e["token-url"]??t.SF_JWT_TOKEN_URL??Ie(o),timeoutMs:$(e.timeout??t.SF_JWT_TIMEOUT,"timeout")??V,skipUserInfo:e["skip-userinfo"]??!1,json:e.json??!1,verbose:e.verbose??!1}}function Wt(e,t){return{probe:!(e["no-probe"]||t.SF_JWT_NO_PROBE==="true"),myDomain:e["my-domain"]??t.SF_JWT_MY_DOMAIN,iss:e.iss??t.SF_JWT_ISS,sub:e.sub??t.SF_JWT_SUB,expiresInSeconds:$(e.exp??t.SF_JWT_EXP,"exp")??qe,expiresAt:$(e["exp-at"]??t.SF_JWT_EXP_AT,"exp-at"),jti:e.jti||t.SF_JWT_JTI==="true",timeoutMs:$(e.timeout??t.SF_JWT_TIMEOUT,"timeout")??V,keyPath:e.key,keyFileEnv:t.SF_JWT_KEY_FILE,inlineKeyEnv:t.SF_JWT_PRIVATE_KEY,passphraseEnv:t.SF_JWT_KEY_PASSPHRASE,json:e.json??!1,verbose:e.verbose??!1}}function Mt(e,t){return{header:e.header,payload:e.payload,expiresInSeconds:$(e.exp,"exp"),expiresAt:$(e["exp-at"],"exp-at"),signing:!!(e.key||t.SF_JWT_KEY_FILE||t.SF_JWT_PRIVATE_KEY),keyPath:e.key,keyFileEnv:t.SF_JWT_KEY_FILE,inlineKeyEnv:t.SF_JWT_PRIVATE_KEY,passphraseEnv:t.SF_JWT_KEY_PASSPHRASE,json:e.json??!1,verbose:e.verbose??!1}}function Lt(e,t){return{jwt:e.jwt,verifyPath:e.verify,passphraseEnv:t.SF_JWT_KEY_PASSPHRASE,json:e.json??!1,verbose:e.verbose??!1}}function Kt(e,t){if(!e)return{alg:t?Z(t):A,typ:"JWT"};let{alg:n,...o}=e;if(!t)return{alg:A,...o};let r=n??Z(t);return R(t,r),{alg:r,...o}}function Yt(e,{expiresInSeconds:t,expiresAt:n,now:o}){return n!==void 0?{...e,exp:n}:t!==void 0?{...e,exp:Math.floor(o/1e3)+t}:{...e}}function Bt(e,{write:t,style:n}){let{outcome:o,http:r,salesforce:s,diagnosis:a,identity:i,userInfo:d,clockSkew:u}=e,c=p=>t(n.dim(p));if(o==="accepted")t(`${n.green("PASS")} ${n.dim(`Salesforce issued an access token (HTTP ${r.status}, ${r.elapsedMs}ms).`)}`),i&&(c(` user: ${i.username}`),c(` user id: ${i.userId}`),c(` org id: ${i.organizationId}`)),e.instanceUrl&&c(` instance: ${e.instanceUrl}`),d&&(t(""),t(n.yellow(` Identity unconfirmed: /userinfo returned HTTP ${d.status}.`)),c(` ${d.rawBody}`),c(" The token itself was issued, so this does not affect the result above."));else if(t(`${n.red("FAIL")} ${n.dim(`Salesforce rejected the assertion (HTTP ${r.status}, ${r.elapsedMs}ms).`)}`),c(` ${s.error??"unknown error"} \u2014 ${s.errorDescription??"no description"}`),a){t(""),c("Likely cause"),c(` ${a.cause}`),t(""),c("What to do");for(let p of a.remediation)c(` - ${p}`)}else t(""),c("No diagnosis matched this error. Raw response:"),c(` ${e.rawBody}`);u!==null&&Math.abs(u)>=It&&(t(""),t(n.yellow(`Clock drift: this machine is ${Math.abs(u)}s ${u>0?"ahead of":"behind"} Salesforce. Drift alone can invalidate an otherwise correct assertion.`)))}function Ht({probe:e,myDomain:t}){if(e&&!t)throw new h("Missing --my-domain, which is where the token gets verified.",{hint:"Pass the org\u2019s My Domain host, or --no-probe to mint without verifying."})}function Vt(e,{warn:t,write:n,style:o}){if(e.error)return t(`Token unverified: ${e.error}`),0;let{outcome:r,cause:s,created:a,remediation:i}=e.diagnosis,{label:d,paint:u}={accepted:{label:"PASS",paint:o.green},rejected:{label:"FAIL",paint:o.red},inconclusive:{label:"????",paint:o.yellow}}[r];n(`${u(d)} ${o.dim(`probe ${e.probeUrl} \u2014 HTTP ${e.status} in ${e.elapsedMs}ms`)}`),n(o.dim(` ${s}`));for(let c of i??[])n(o.dim(` - ${c}`));if(a!=null){n("");for(let c of["The probe created a VoiceCall record. It was built so that Salesforce would",`reject it, and Salesforce did not. Delete ${a} and open an issue \u2014`,"this tool must not write to your org."])n(o.alarm(`!!!! ${c}`))}return r==="rejected"?1:0}async function qt(e,{env:t,stdout:n,stderr:o,stdin:r,fetchImpl:s,now:a}){let i=Wt(e,t),d=O({verbose:i.verbose,stream:o,env:t});Me(i),Ht(i);let u=i.probe?Ce(i.myDomain):null;d.section("Private key");let c=await Y({...i,stdin:r});d.detail("source",c.source),d.detail("type",`${D(c)}${c.encrypted?", passphrase-protected":""}`);let p=a(),l=We({...i,jti:i.jti?Je():void 0,now:p}),m=l.exp-l.iat;d.section("Claims");for(let[S,f]of Object.entries(l))d.detail(S,f);d.detail("exp at",`${new Date(l.exp*1e3).toISOString()} (in ${H(m)})`);let y=ee(l,c.keyObject),g=null;if(i.probe){let S=Re({callCenterApiName:i.sub,now:p,jti:l.jti});try{let f=await De({...i,probeUrl:u,token:y,payload:S,fetchImpl:s,now:a});d.httpRequest(f.request,"Probe"),d.httpResponse(f,"Probe"),g={probeUrl:u,status:f.status,elapsedMs:f.elapsedMs,vendorCallKey:S.vendorCallKey,rawBody:f.rawBody,diagnosis:Ue(f),error:null}}catch(f){g={probeUrl:u,error:f.message,diagnosis:null}}}let w=!!(n.isTTY&&o.isTTY);return(i.verbose||w)&&o.write(`
206
+ `),n.write(i.json?`${JSON.stringify({token:y,claims:l,probe:g},null,2)}
207
+ `:`${y}
208
+ `),g?(w&&o.write(`
209
+ `),Vt(g,{warn:S=>d.warn(S),write:S=>o.write(`${S}
210
+ `),style:d.style})):0}var Ye=e=>typeof e=="object"&&e!==null?JSON.stringify(e):e;async function Xt(e,{env:t,stdout:n,stderr:o,stdin:r,now:s}){let a=Mt(e,t),i=O({verbose:a.verbose,stream:o,env:t});if(!a.payload)throw new h("Missing --payload (the claims to sign).",{hint:"Pass JSON directly, a path to a file holding it, or - to read stdin."});ne(e,["header","payload","key"]);let d=await L(a.payload,{flag:"payload",kind:"json",stdin:r}),u=te(d.text,"payload"),c=null;if(a.header!==void 0){let w=await L(a.header,{flag:"header",kind:"json",stdin:r});c=te(w.text,"header")}let p=null;a.signing&&(i.section("Private key"),p=await Y({...a,stdin:r,assertUsable:K}),i.detail("source",p.source),i.detail("type",`${D(p)}${p.encrypted?", passphrase-protected":""}`));let l=Kt(c,p?.keyObject??null),m=Yt(u,{...a,now:s()});i.section("Header");for(let[w,S]of Object.entries(l))i.detail(w,Ye(S));i.section("Claims");for(let[w,S]of Object.entries(m))i.detail(w,Ye(S));let y=p?U(l,m,p.keyObject):ye(l,m),g=!!(n.isTTY&&o.isTTY);return(a.verbose||g)&&o.write(`
211
+ `),n.write(a.json?`${JSON.stringify({token:y,header:l,claims:m},null,2)}
212
+ `:`${y}
213
+ `),p||(g&&o.write(`
214
+ `),i.warn("This token is not signed: no --key, and no SF_JWT_KEY_FILE or SF_JWT_PRIVATE_KEY set."),c?.alg&&c.alg!==A&&i.warn(`Its header asked for ${c.alg}; with nothing to sign with, alg is "${A}".`)),0}function Gt({keyMatch:e,times:t},{write:n,style:o}){let r=" ".repeat(9);if(e){let{label:a,paint:i}=e.outcome==="valid"?{label:"VALID",paint:o.green}:{label:"INVALID",paint:o.red};n(`${i(a)}${" ".repeat(r.length-a.length)}${o.dim(`key match \u2014 ${e.reason}`)}`)}else n(o.dim(`${r}Signature unchecked. Pass --verify <certificate|key> to establish a key match.`));let s=["iat","nbf","exp"].filter(a=>t[a]);if(s.length===0){n(o.dim(`${r}No iat, nbf or exp to report.`));return}for(let a of s){let{at:i,deltaSeconds:d,value:u}=t[a],c=d>=0?`in ${H(d)}`:`${H(-d)} ago`;n(o.dim(`${r}${`${a}:`.padEnd(6)}${i??u} (${c})`))}t.expired&&n(o.yellow(`${r}Expired. That is a fact about the clock, not about the signature.`)),t.notYetValid&&n(o.yellow(`${r}Not yet valid: nbf is in the future.`));for(let a of s)t[a].looksLikeMilliseconds&&n(o.yellow(`${r}${a} looks like milliseconds rather than seconds.`))}async function zt(e,{env:t,stdout:n,stderr:o,stdin:r,now:s}){let a=Lt(e,t),i=O({verbose:a.verbose,stream:o,env:t});if(!a.jwt)throw new h("Missing --jwt (the token to read).",{hint:"Pass the token itself, a path to a file holding it, or - to read stdin."});ne(e,["jwt","verify"]);let d=await L(a.jwt,{flag:"jwt",kind:"token",stdin:r}),u=Q(d.text),c=ge(u.claims,s());i.section("Token"),i.detail("source",d.source),i.detail("alg",u.header.alg??"(absent)"),i.detail("signature",u.signature===""?"(empty)":`${u.signature.length} chars`);let p=null;if(a.verifyPath!==void 0){let y=await ve({path:a.verifyPath,passphraseEnv:a.passphraseEnv,stdin:r});if(i.section("Verification material"),i.detail("source",y.source),i.detail("kind",`${y.kind}, ${D(K(y.keyObject))}`),!u.signed)p={outcome:"invalid",reason:`the token carries no signature to match (alg "${u.header.alg??"absent"}")`};else{if(!u.header.alg)throw new h("The token's header names no alg, so there is no way to verify it.");let g=Se(u,y.keyObject);p={outcome:g?"valid":"invalid",alg:u.header.alg,source:y.source,kind:y.kind,reason:`signature ${g?"verifies":"does not verify"} against ${y.source} (${y.kind}, ${u.header.alg})`}}}let l=!!(n.isTTY&&o.isTTY);(a.verbose||l)&&o.write(`
215
+ `);let m=a.json?{header:u.header,claims:u.claims,signed:u.signed,keyMatch:p,times:c}:{header:u.header,claims:u.claims};return n.write(`${JSON.stringify(m,null,2)}
216
+ `),l&&o.write(`
217
+ `),Gt({keyMatch:p,times:c},{write:y=>o.write(`${y}
218
+ `),style:i.style}),p?.outcome==="invalid"?1:0}var Zt={[_]:Nt,[k]:Dt,[b]:Ot,[v]:Ft};async function Xe(e=process.argv.slice(2),{env:t=process.env,stdout:n=process.stdout,stderr:o=process.stderr,stdin:r=process.stdin,fetchImpl:s=fetch,now:a=Date.now}={}){let i=se(re(o,t)),d,u;try{({values:d,positionals:u}=At({args:e,options:Pt,allowPositionals:!0}))}catch(c){return o.write(`${i.red(c.message)}
219
+ ${i.dim("Run with --help for usage.")}
220
+ `),2}try{let c=Ct(u);if(d.help||Object.keys(d).length===0&&Jt(t))return n.write(Zt[c]),0;if(d.version)return n.write(`${I}
221
+ `),0;if(o.write(`${i.dim(`sf-jwt ${I}`)}
222
+ `),Rt(c,d),c===k)return await qt(d,{env:t,stdout:n,stderr:o,stdin:r,fetchImpl:s,now:a});if(c===b)return await Xt(d,{env:t,stdout:n,stderr:o,stdin:r,now:a});if(c===v)return await zt(d,{env:t,stdout:n,stderr:o,stdin:r,now:a});let p=Ut(d,t),l=O({verbose:p.verbose,stream:o,env:t});l.section("Private key");let m=await Y({...p,stdin:r});l.detail("source",m.source),l.detail("type",`${D(m)}${m.encrypted?", passphrase-protected":""}`);let y=a(),g=we({...p,now:y}),w=g.exp-Math.floor(y/1e3);l.section("Claims");for(let[j,T]of Object.entries(g))l.detail(j,T);l.detail("exp at",`${new Date(g.exp*1e3).toISOString()} (in ${H(w)})`);let S=ee(g,m.keyObject);l.secret("assertion",S);let f=await Ne({...p,assertion:S,fetchImpl:s,now:a});l.httpRequest(f.request,"Token"),l.httpResponse(f,"Token");let F=je(f.serverDate,f.localMidpointMs),x={outcome:f.ok?"accepted":"rejected",tokenUrl:p.tokenUrl,claims:g,lifetimeSeconds:w,http:{status:f.status,elapsedMs:f.elapsedMs},clockSkew:F,clockSkewUncertainty:F===null?null:Math.ceil(f.elapsedMs/2e3)+1,rawBody:f.rawBody,salesforce:{error:f.body?.error,errorDescription:f.body?.error_description},diagnosis:null,identity:null,userInfo:null,instanceUrl:f.body?.instance_url??null};if(f.ok){if(!p.skipUserInfo&&f.body?.access_token){let j=f.body.instance_url??new URL(p.tokenUrl).origin,T=await Oe({userInfoUrl:Pe(j),accessToken:f.body.access_token,timeoutMs:p.timeoutMs,fetchImpl:s,now:a});l.httpRequest(T.request,"Identity"),l.httpResponse(T,"Identity"),T.ok?x.identity={username:T.body?.preferred_username??T.body?.email??"unknown",userId:T.body?.user_id??"unknown",organizationId:T.body?.organization_id??"unknown"}:(x.userInfo={status:T.status,error:T.body?.error??null,errorDescription:T.body?.error_description??null,rawBody:T.rawBody},l.warn(`The token was issued; it is /userinfo that returned ${T.status}.`))}}else x.diagnosis=Te(x.salesforce,g);F!==null&&(l.section("Clock"),l.detail("drift",`${F}s versus Salesforce (\xB1${x.clockSkewUncertainty}s on a ${f.elapsedMs}ms round trip)`));let q=f.ok?0:1;if(p.json)return p.verbose&&o.write(`
223
+ `),n.write(`${JSON.stringify({...x,exitCode:q},null,2)}
224
+ `),q;let X=f.body?.access_token??null,le=!!(n.isTTY&&o.isTTY);return(p.verbose||X&&le)&&o.write(`
225
+ `),X&&(n.write(`${X}
226
+ `),le&&o.write(`
227
+ `)),Bt(x,{write:j=>o.write(`${j}
228
+ `),style:l.style}),q}catch(c){if(c.exitCode)return o.write(`${i.red(c.message)}
229
+ `),c.hint&&o.write(`${i.dim(` ${c.hint}`)}
230
+ `),c.exitCode;throw c}}process.exitCode=await Xe(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@wgroovy/sf-jwt",
3
+ "version": "1.2.0",
4
+ "description": "Sign, decode and verify JSON Web Tokens, with a speciality in Salesforce's OAuth 2.0 JWT bearer flow and Telephony Integration REST API",
5
+ "license": "MIT",
6
+ "author": "wgroovy",
7
+ "type": "module",
8
+ "main": "dist/sf-jwt.js",
9
+ "bin": {
10
+ "sf-jwt": "dist/sf-jwt.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "scripts": {
22
+ "build": "esbuild bin/sf-jwt.js --bundle --platform=node --target=node22 --format=esm --minify --legal-comments=none --define:__VERSION__=\"'$npm_package_version'\" --outfile=dist/sf-jwt.js && chmod +x dist/sf-jwt.js",
23
+ "prepublishOnly": "npm run build",
24
+ "test": "node --test",
25
+ "lint": "eslint .",
26
+ "lint:fix": "eslint . --fix",
27
+ "format": "prettier --write .",
28
+ "format:check": "prettier --check ."
29
+ },
30
+ "keywords": [
31
+ "salesforce",
32
+ "oauth",
33
+ "oauth2",
34
+ "jwt",
35
+ "jws",
36
+ "jwt-decode",
37
+ "jwt-bearer",
38
+ "rs256",
39
+ "es256",
40
+ "cli",
41
+ "external-client-app",
42
+ "connected-app",
43
+ "service-cloud-voice",
44
+ "salesforce-voice",
45
+ "telephony"
46
+ ]
47
+ }