@hawcx/oauth-client 5.2.0 → 6.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 (69) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +247 -0
  3. package/dist/client-assertion.d.ts.map +1 -1
  4. package/dist/client-assertion.js +6 -2
  5. package/dist/client-assertion.js.map +1 -1
  6. package/dist/delegation/exceptions.d.ts +34 -0
  7. package/dist/delegation/exceptions.d.ts.map +1 -1
  8. package/dist/delegation/exceptions.js +39 -1
  9. package/dist/delegation/exceptions.js.map +1 -1
  10. package/dist/delegation/hawcx.d.ts +6 -2
  11. package/dist/delegation/hawcx.d.ts.map +1 -1
  12. package/dist/delegation/hawcx.js.map +1 -1
  13. package/dist/delegation/index.d.ts +2 -1
  14. package/dist/delegation/index.d.ts.map +1 -1
  15. package/dist/delegation/index.js +3 -1
  16. package/dist/delegation/index.js.map +1 -1
  17. package/dist/delegation/transport.d.ts +6 -2
  18. package/dist/delegation/transport.d.ts.map +1 -1
  19. package/dist/delegation/transport.js +9 -2
  20. package/dist/delegation/transport.js.map +1 -1
  21. package/dist/errors.d.ts +11 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +1 -0
  24. package/dist/errors.js.map +1 -1
  25. package/dist/index.d.ts +6 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +8 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/url.d.ts +73 -0
  30. package/dist/internal/url.d.ts.map +1 -0
  31. package/dist/internal/url.js +210 -0
  32. package/dist/internal/url.js.map +1 -0
  33. package/dist/management/jwt_transport.d.ts +44 -3
  34. package/dist/management/jwt_transport.d.ts.map +1 -1
  35. package/dist/management/jwt_transport.js +60 -4
  36. package/dist/management/jwt_transport.js.map +1 -1
  37. package/dist/management/response_signature.d.ts +65 -0
  38. package/dist/management/response_signature.d.ts.map +1 -0
  39. package/dist/management/response_signature.js +170 -0
  40. package/dist/management/response_signature.js.map +1 -0
  41. package/dist/oauth.d.ts +89 -1
  42. package/dist/oauth.d.ts.map +1 -1
  43. package/dist/oauth.js +265 -34
  44. package/dist/oauth.js.map +1 -1
  45. package/dist/profile/client.d.ts +99 -0
  46. package/dist/profile/client.d.ts.map +1 -0
  47. package/dist/profile/client.js +209 -0
  48. package/dist/profile/client.js.map +1 -0
  49. package/dist/profile/errors.d.ts +62 -0
  50. package/dist/profile/errors.d.ts.map +1 -0
  51. package/dist/profile/errors.js +90 -0
  52. package/dist/profile/errors.js.map +1 -0
  53. package/dist/profile/http.d.ts +57 -0
  54. package/dist/profile/http.d.ts.map +1 -0
  55. package/dist/profile/http.js +295 -0
  56. package/dist/profile/http.js.map +1 -0
  57. package/dist/profile/tokens.d.ts +50 -0
  58. package/dist/profile/tokens.d.ts.map +1 -0
  59. package/dist/profile/tokens.js +137 -0
  60. package/dist/profile/tokens.js.map +1 -0
  61. package/dist/profile/types.d.ts +99 -0
  62. package/dist/profile/types.d.ts.map +1 -0
  63. package/dist/profile/types.js +7 -0
  64. package/dist/profile/types.js.map +1 -0
  65. package/dist/stepup/client.d.ts +11 -0
  66. package/dist/stepup/client.d.ts.map +1 -1
  67. package/dist/stepup/client.js +6 -1
  68. package/dist/stepup/client.js.map +1 -1
  69. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,361 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [6.2.0] - 2026-11-02
11
+
12
+ Additive, apart from these `exchangeCode` / token-endpoint behaviour changes
13
+ (details under Changed): a 3xx from the token endpoint is no longer followed
14
+ and throws `TokenExchangeError`; a 2xx token response that is not a JSON
15
+ object throws `TokenExchangeError` instead of a raw `SyntaxError`/`TypeError`
16
+ (or being returned as-is); a 2xx body cut off mid-read throws a network
17
+ `TokenExchangeError` carrying that 2xx `statusCode`.
18
+
19
+ ### Added
20
+
21
+ - **Profile service client.** `ProfileClient` (`oauth.profile(options?)` or
22
+ `new ProfileClient(oauth, options?)`), built from a discovery-mode
23
+ `HawcxOAuth` with a `private_key_jwt` signer. Machine methods (RFC 6749 §4.4
24
+ `client_credentials`): `getUser`, `listUsers`/`iterUsers`, `createUser`,
25
+ `updateUser`, `replaceUser`, `deleteUser`, `getConsents`, `setConsent`,
26
+ `getAudit`/`iterAudit`. `forUser(idToken)` returns a `UserProfileClient`
27
+ (RFC 8693 exchange of the ID token for a `profile:self` token): `getMe`,
28
+ `updateMe`, `getMyConsents`, `setMyConsent`, `getMyAudit`/`iterMyAudit`.
29
+ Tokens are cached with early refresh and single-flight minting; expiry is
30
+ counted from when the token request was sent. The early refresh runs in the
31
+ background, so every caller (the one that triggered it included) gets the
32
+ still-valid cached token; if it fails (any error, e.g. a signer error too)
33
+ that token stays in use and no other early refresh is tried for 30 s (or
34
+ until expiry, if sooner). `forUser` tokens are not refreshed early (the OP
35
+ caps their `exp` at the ID token's), only at expiry. A 401 is
36
+ replayed once with a fresh token; for `forUser`, if that re-exchange fails
37
+ with a non-transient token error (e.g. `invalid_grant`, the ID token has
38
+ expired), the original 401 `ProfileApiError` is thrown (token error as
39
+ `cause`); GET/PUT/DELETE retry on transient
40
+ failures (Retry-After honoured, capped at 30 s); a token-endpoint network
41
+ error, 408, 429, 502, 503 or 504 is retried for every method within the same
42
+ budget (no profile request was sent), with backoff only (its Retry-After is
43
+ ignored); POST/PATCH are otherwise never
44
+ retried; a DELETE answered 404 counts as success once any earlier attempt of
45
+ the call may have run (no HTTP response, or 504; not 502, which is
46
+ ambiguous but left out because a false "deleted" is worse). Errors that
47
+ mean nothing was sent do not count: `fetch()` rejecting with a connect/DNS
48
+ or TLS-handshake code (ECONNREFUSED, ENOTFOUND, EAI_*, connect timeouts,
49
+ CERT_*/ERR_TLS_*/ERR_SSL_*), or a routing code (EHOSTUNREACH, ENETUNREACH,
50
+ EHOSTDOWN, ENETDOWN, EADDRNOTAVAIL) with `syscall: 'connect'`; an injected
51
+ `fetch` whose errors carry none of these counts as may-have-run. A failure
52
+ reading a 2xx body always counts, and its error keeps the 2xx status
53
+ (title `Response body read failed`) rather than 0, since the call ran.
54
+ Redirects are not
55
+ followed; path parameters, and `createUser`'s `user_id`, must be one segment
56
+ (`/`, `\` and control characters refused). `deleteUser` counts any 2xx as
57
+ success, whatever its body (empty, whitespace, not JSON, not an object).
58
+ Iterators stop on a null, missing or empty-string `next_cursor`. An
59
+ explicitly empty `scopes` list throws `HawcxError` when the client is built
60
+ (omit `scopes` for the client's default). `timeout` (default 10000) bounds
61
+ each token-endpoint call too, not only profile requests, whatever the
62
+ `HawcxOAuth` client's own `timeout`. `ProfilePatch.status` is `string` (no
63
+ `null`: the server answers 422 to a null status).
64
+ - **`forUser` is for right after sign-in.** The OP caps the delegated token's
65
+ `exp` at the ID token's (`id_token_ttl_seconds`, 60 s by default), so pass
66
+ the ID token `exchangeCode` just returned; for later work on a user's
67
+ profile use the machine methods with their `user_id` (the `sub` claim).
68
+ - **`ProfileApiError`** (`HawcxError` subclass) mapped from RFC 9457
69
+ problem+json: `status`, `title`, `detail`, `type`, `instance`, `requestId`,
70
+ `retryAfter`, `missing`, `fields`, `body`, `rawBody` (the body text, capped
71
+ at 4096 UTF-16 code units, kept out of `message`; on a malformed 2xx only
72
+ when the body is a markup page starting with `<`, since any other body may
73
+ be a mangled profile, i.e. PII). A Kong-style `{"message"}` body
74
+ (no non-blank `title`/`detail`) uses `message` as `detail`; `requestId`
75
+ falls back to the body's `request_id`. A non-204 2xx profile body that is
76
+ not a JSON object (or lacks a required field), and a 204, to any method but
77
+ `deleteUser`, throw `ProfileApiError` "Unexpected response body"; an
78
+ unparseable body "Invalid JSON response".
79
+
80
+ ### Changed
81
+
82
+ - `exchangeCode` no longer follows redirects from the token endpoint: a 3xx is
83
+ now a `TokenExchangeError`, so the code, PKCE verifier and client assertion
84
+ are never re-posted to another host. A token-endpoint network error now
85
+ carries the underlying error as `cause` and sets the new
86
+ `TokenExchangeError.network` flag (`true` when no complete HTTP response
87
+ came back, including a 2xx body cut off mid-read, which keeps the 2xx in
88
+ `statusCode`). A 2xx token response
89
+ that was read in full but is not a JSON object now throws a non-network
90
+ `TokenExchangeError` ("Invalid JSON in token response") instead of a raw
91
+ `SyntaxError`/`TypeError`.
92
+
93
+ ## [6.1.0] - 2026-10-12
94
+
95
+ Additive; no behaviour change for existing callers.
96
+
97
+ ### Added
98
+
99
+ - **Custom domains.** Optional `customDomain` on
100
+ `HawcxOAuth.fromIssuer(...)` (e.g. `https://auth.example.com`), validated as
101
+ a raw string (same rules as the Python and Java SDKs): `scheme://host[:port]`
102
+ only, https (http only for loopback), ASCII/punycode hostname (no `*`, `_`,
103
+ trailing dot, numeric-looking last label) or canonical IP literal, port
104
+ 1-65535 without leading zeros, no userinfo, path, query, fragment,
105
+ whitespace, `\` or `%`; IPv6 without embedded IPv4. Canonicalised
106
+ (lower-cased, default port dropped, IPv6 in RFC 5952 form, loopback checked
107
+ on the canonical host).
108
+ `null`/`undefined` = unset. An invalid value throws `HawcxOAuthError`
109
+ (`error: 'invalid_custom_domain'`) from `fromIssuer` before any fetch. It
110
+ changes only the browser URLs below; discovery, token exchange, JWKS and the
111
+ `iss` check stay on the issuer.
112
+ - **`buildAuthorizeUrl(options)`** builds the `/authorize` URL from the
113
+ discovered `authorization_endpoint`: origin (custom domain if set, else the
114
+ endpoint's raw scheme and authority) + the
115
+ endpoint's raw path and query, copied verbatim, then `response_type=code`,
116
+ `client_id`, `redirect_uri`, `scope` (blank → `openid`), `state`,
117
+ `code_challenge`, `code_challenge_method=S256`, and optional `nonce`,
118
+ `prompt` (blank = empty or ASCII whitespace only → omitted) and `extraParams` (non-empty keys, string values,
119
+ insertion order kept). `extraParams` cannot set any SDK parameter, `request`
120
+ or `request_uri`, nor a name already in the endpoint query. Missing or blank `redirectUri` / `state` / `codeChallenge`
121
+ or a bad `extraParams` entry throws `HawcxOAuthError`
122
+ (`error: 'invalid_request'`).
123
+ - **`buildEndSessionUrl(options?)`** builds the logout URL from the discovered
124
+ `end_session_endpoint` the same way; always sends `client_id`, plus
125
+ `post_logout_redirect_uri`, `id_token_hint` and `state` when non-blank.
126
+ `null` options = `{}`.
127
+ - Both helpers are discovery-mode only (legacy mode throws `HawcxOAuthError`,
128
+ `error: 'issuer_mode_required'`). A discovered endpoint that is missing, not
129
+ https (loopback http ok), has userinfo, no host, any `#`, a character
130
+ outside printable ASCII or any of `` \ " < > ^ ` { | } ``, or a query that
131
+ already sets one of the helper's SDK parameters (authorize: also `request`,
132
+ `request_uri`) throws `DiscoveryError`
133
+ (`error: 'invalid_discovery_document'`) when the helper is called, never
134
+ from `fromIssuer`, so discovery documents without these fields keep working.
135
+ - URLs are semantically, not byte-for-byte, equal across the Node, Python and
136
+ Java SDKs (parameter encoding differs on e.g. `~`, `*`, space).
137
+ - New exported types `AuthorizeUrlOptions` and `EndSessionUrlOptions`.
138
+
139
+ ## [6.0.0] - 2026-10-10
140
+
141
+ First public release since 5.2.0. **Major version** because of the BREAKING
142
+ entries below, which change behaviour for configurations that 5.2.0 accepted.
143
+ It contains everything from the internal release candidates 6.0.0-rc.1 and
144
+ 6.0.0-rc.2, plus one fix to `ClientAssertionSigner` made after rc.2.
145
+
146
+ ### Breaking — action needed when upgrading from 5.2.0
147
+
148
+ - **Management API response signatures are verified by default
149
+ (TOB-HSDK-11).** `JwtBearerTransport` and `StepUpClient.withPrivateKeyJwt`
150
+ now verify `X-Response-Signature` on every successful response. This
151
+ reverses the 5.2.0 note that callers "may" verify the header themselves.
152
+ The SDK checks the following:
153
+ - the JWS uses ES256, ES384 or ES512 and verifies against
154
+ `${issuer}/.well-known/jwks.json`;
155
+ - `iat` is within `clockSkewSeconds` (default 300);
156
+ - `body_sha256` matches the exact response bytes;
157
+ - the token carries no `aud`, `sub` or `nonce`.
158
+
159
+ Responses with status 400 and above are not verified. hx_auth signs every
160
+ jwt-mode 2xx, so production traffic is unaffected. **Action needed** if any
161
+ of these apply:
162
+ - A gateway, CDN or proxy strips response headers. Every call fails with
163
+ `ResponseSignatureInvalidError`; let `X-Response-Signature` through.
164
+ - Mocks, stubs or contract-test harnesses built against 5.2.0 return
165
+ unsigned 2xx responses. Sign the responses, or disable verification in the
166
+ test harness.
167
+ - `baseUrl` is not the OP host, such as an internal service URL or local dev
168
+ with separate ports. The JWKS is fetched from `baseUrl` by default, so set
169
+ `issuer`.
170
+ - **`baseUrl` (and `issuer`) values that used to be accepted now throw at
171
+ construction (TOB-HSDK-13).** The affected classes are `JwtBearerTransport`
172
+ and `StepUpClient.withPrivateKeyJwt` (`DelegationRequestError`),
173
+ `DelegationTransport` and the `DelegationClient`/`StepUpClient` ECIES
174
+ factories (`DelegationRequestError`), legacy `HawcxOAuth`
175
+ (`HawcxOAuthError`), and `HawcxOAuth.fromIssuer` (`DiscoveryError`):
176
+ - **Plain `http:` on a non-loopback host.** Examples:
177
+ `http://hawcx-api:8080` (docker-compose service name),
178
+ `http://0.0.0.0:8080`, `http://host.docker.internal:8080`, and in-cluster
179
+ service URLs behind a TLS-terminating gateway. Only `localhost`,
180
+ `127.0.0.1` and `[::1]` may use `http:`. **Action needed:** use `https:`,
181
+ or point the SDK at a loopback address.
182
+ - **Userinfo, query or fragment in the base.** For example
183
+ `https://user:pw@api.hawcx.com`, `https://api.hawcx.com?tenant=x` or
184
+ `https://api.hawcx.com#x`. **Action needed:** remove them.
185
+ - **The signed JWT `aud` string changes for non-canonical base spellings
186
+ (TOB-HSDK-13).** Host case and default ports are now normalised, so
187
+ `baseUrl: 'https://API.Hawcx.com:443'` produces
188
+ `aud = https://api.hawcx.com/v1/...`, where 5.2.0 produced
189
+ `https://API.Hawcx.com:443/v1/...`. The new value is the one `fetch`
190
+ actually sends. **Action needed** only if a server compares `aud` by exact
191
+ string against a configured non-canonical value: update that value, or use
192
+ a canonical `baseUrl`. Canonical bases (lower-case host, no default port,
193
+ with or without a trailing slash or a path prefix) produce byte-identical
194
+ URLs and `aud`.
195
+
196
+ ### Added
197
+
198
+ - New options on `JwtBearerTransport` and `StepUpClient.withPrivateKeyJwt`:
199
+ `issuer` (defaults to `baseUrl`), `verifyResponseSignature` (defaults to
200
+ `true`) and `clockSkewSeconds`. `verifyResponseSignature: false` is an
201
+ **emergency escape hatch** that restores the 5.2.0 behaviour and emits a
202
+ one-time process warning. `withPrivateKeyJwt` now also passes
203
+ `clockSkewSeconds` through to the transport.
204
+ - New error classes, both subclasses of `DelegationResponseError`, exported
205
+ with a stable `code`:
206
+ - `ResponseSignatureInvalidError` (`response_signature_invalid`).
207
+ - `ResponseSignatureUnavailableError` (`response_signature_unavailable`).
208
+ It means the signing keys could not be fetched, and **the request may
209
+ already have been applied** server-side.
210
+
211
+ ### Changed
212
+
213
+ - Every security-relevant URL is now parsed and joined with the WHATWG `URL`
214
+ API rather than by string concatenation (TOB-HSDK-13). This covers
215
+ `JwtBearerTransport` and `DelegationTransport` (`baseUrl` + endpoint),
216
+ legacy `new HawcxOAuth({ baseUrl })` (`/oauth2/token` and `/keys`), and the
217
+ `fromIssuer` issuer and discovery URL. Each joined URL must keep the base's
218
+ origin and stay under the base path prefix, or the SDK throws (fail
219
+ closed). An endpoint must be a relative path that starts with a single
220
+ `/`. It may not contain `//`, a backslash, whitespace or control
221
+ characters, a fragment, or a percent-encoded `/`, `.` or `\` (`%2f`,
222
+ `%2e`, `%5c`, any case) in its path.
223
+ - Discovered `token_endpoint` and `jwks_uri` values carrying userinfo are now
224
+ rejected (`DiscoveryError`).
225
+ - The `clearCache()` deprecation warning now says it will be removed in a
226
+ future major version rather than in 6.0.0. It is still deprecated (use
227
+ `refreshJwks()`) and still present in 6.0.0.
228
+
229
+ ### Fixed
230
+
231
+ - **Discovery mode now sends `client_id` on the public-client (PKCE) token
232
+ exchange.** Previously `exchangeCode()` added `client_id` to the token form
233
+ only when a `private_key_jwt` signer was set; a public PKCE exchange sent
234
+ only the `X-Config-Id` header. The OP then fell back to the project's
235
+ default client (`token.legacy_config_id_fallback`) and stamped the id_token
236
+ `aud` with that client's id, so on a project with more than one public OIDC
237
+ client, an app using a non-default client failed verification with
238
+ "Unexpected JWT audience". `exchangeCode()` now always sends the configured
239
+ `clientId` as `client_id` in discovery mode (RFC 6749 §4.1.3), for public
240
+ and `private_key_jwt` clients alike; the OP resolves that specific client
241
+ and stamps `aud` with it. `X-Config-Id` is still sent.
242
+ - Single-client projects are unaffected: their `clientId` is the default
243
+ client, so the resolved client and `aud` are the same as before.
244
+ - Legacy mode (`new HawcxOAuth({ configId, baseUrl })`) has no `clientId`
245
+ and is unchanged: it sends no `client_id`.
246
+ - `ClientAssertionSigner.sign()` now reads the clock once for `iat` and `exp`.
247
+ Two separate reads could straddle a second boundary and make the assertion
248
+ one second longer than its lifetime; at `withLifetimeSeconds(120)` that
249
+ exceeds the OP's 120s cap and the token exchange is rejected.
250
+
251
+ ## [6.0.0-rc.2] - 2026-10-10
252
+
253
+ > **Release candidate, internal only.** Published to AWS CodeArtifact
254
+ > (`npm-internal`), not public npm; the release workflow refuses to publish a
255
+ > prerelease publicly. Contains everything in 6.0.0-rc.1 plus the fix below.
256
+ > To install, point the `@hawcx` scope at CodeArtifact
257
+ > (`aws codeartifact login --tool npm --domain hawcx --repository npm-internal --namespace @hawcx`)
258
+ > and run `npm install @hawcx/oauth-client@6.0.0-rc.2`.
259
+
260
+ ### Fixed
261
+
262
+ - **Discovery mode now sends `client_id` on the public-client (PKCE) token
263
+ exchange.** Previously `exchangeCode()` added `client_id` to the token form
264
+ only when a `private_key_jwt` signer was set; a public PKCE exchange sent
265
+ only the `X-Config-Id` header. The OP then fell back to the project's
266
+ default client (`token.legacy_config_id_fallback`) and stamped the id_token
267
+ `aud` with that client's id, so on a project with more than one public OIDC
268
+ client, an app using a non-default client failed verification with
269
+ "Unexpected JWT audience". `exchangeCode()` now always sends the configured
270
+ `clientId` as `client_id` in discovery mode (RFC 6749 §4.1.3), for public
271
+ and `private_key_jwt` clients alike; the OP resolves that specific client
272
+ and stamps `aud` with it. `X-Config-Id` is still sent.
273
+ - Single-client projects are unaffected: their `clientId` is the default
274
+ client, so the resolved client and `aud` are the same as before.
275
+ - Legacy mode (`new HawcxOAuth({ configId, baseUrl })`) has no `clientId`
276
+ and is unchanged: it sends no `client_id`.
277
+
278
+ ## [6.0.0-rc.1] - 2026-10-07
279
+
280
+ > **Release candidate, internal only.** Published to AWS CodeArtifact
281
+ > (`npm-internal`), not public npm; the release workflow refuses to publish a
282
+ > prerelease publicly. **Major version** because of the BREAKING entries below,
283
+ > which change behaviour for configurations that 5.2.0 accepted. To install,
284
+ > point the `@hawcx` scope at CodeArtifact
285
+ > (`aws codeartifact login --tool npm --domain hawcx --repository npm-internal --namespace @hawcx`)
286
+ > and run `npm install @hawcx/oauth-client@6.0.0-rc.1`.
287
+
288
+ ### Security — management API response signatures are verified by default (TOB-HSDK-11)
289
+
290
+ - **BREAKING — `JwtBearerTransport` and `StepUpClient.withPrivateKeyJwt` now
291
+ verify `X-Response-Signature` on every successful response, and this is on
292
+ by default.** This reverses the 5.2.0 note that callers "may" verify the
293
+ header themselves. The SDK checks the following:
294
+ - the JWS uses ES256, ES384 or ES512 and verifies against
295
+ `${issuer}/.well-known/jwks.json`;
296
+ - `iat` is within `clockSkewSeconds` (default 300);
297
+ - `body_sha256` matches the exact response bytes;
298
+ - the token carries no `aud`, `sub` or `nonce`.
299
+
300
+ Responses with status 400 and above are not verified. hx_auth signs every
301
+ jwt-mode 2xx, so production traffic is unaffected. The following will
302
+ **break**:
303
+ - A gateway, CDN or proxy that strips response headers. Every call fails
304
+ with `ResponseSignatureInvalidError`.
305
+ - Customer mocks, stubs and contract-test harnesses built against 5.2.0 that
306
+ return unsigned 2xx responses. Sign the responses, or disable verification
307
+ in the test harness.
308
+ - A `baseUrl` that is not the OP host, such as an internal service URL or
309
+ local dev with separate ports. The JWKS is fetched from `baseUrl` by
310
+ default, so set `issuer`.
311
+ - New options on both: `issuer` (defaults to `baseUrl`),
312
+ `verifyResponseSignature` (defaults to `true`) and `clockSkewSeconds`.
313
+ `verifyResponseSignature: false` is an **emergency escape hatch** that
314
+ restores the 5.2.0 behaviour and emits a one-time process warning.
315
+ `withPrivateKeyJwt` now also passes `clockSkewSeconds` through to the
316
+ transport.
317
+ - New error classes, both subclasses of `DelegationResponseError`, exported
318
+ with a stable `code`:
319
+ - `ResponseSignatureInvalidError` (`response_signature_invalid`).
320
+ - `ResponseSignatureUnavailableError` (`response_signature_unavailable`).
321
+ It means the signing keys could not be fetched, and **the request may
322
+ already have been applied** server-side.
323
+
324
+ ### Security — structured URL validation and joining (TOB-HSDK-13)
325
+
326
+ - Every security-relevant URL is now parsed and joined with the WHATWG `URL`
327
+ API rather than by string concatenation. This covers `JwtBearerTransport`
328
+ and `DelegationTransport` (`baseUrl` + endpoint), legacy
329
+ `new HawcxOAuth({ baseUrl })` (`/oauth2/token` and `/keys`), and the
330
+ `fromIssuer` issuer and discovery URL. Each joined URL must keep the base's
331
+ origin and stay under the base path prefix, or the SDK throws (fail
332
+ closed). An endpoint must be a relative path that starts with a single
333
+ `/`. It may not contain `//`, a backslash, whitespace or control
334
+ characters, a fragment, or a percent-encoded `/`, `.` or `\` (`%2f`,
335
+ `%2e`, `%5c`, any case) in its path.
336
+ - **BREAKING — `baseUrl` (and `issuer`) values that used to be accepted now
337
+ throw at construction.** The affected classes are `JwtBearerTransport` and
338
+ `StepUpClient.withPrivateKeyJwt` (`DelegationRequestError`),
339
+ `DelegationTransport` and the `DelegationClient`/`StepUpClient`
340
+ ECIES factories (`DelegationRequestError`), legacy `HawcxOAuth`
341
+ (`HawcxOAuthError`), and `HawcxOAuth.fromIssuer` (`DiscoveryError`):
342
+ - **Plain `http:` on a non-loopback host.** Examples:
343
+ `http://hawcx-api:8080` (docker-compose service name),
344
+ `http://0.0.0.0:8080`, `http://host.docker.internal:8080`, and in-cluster
345
+ service URLs behind a TLS-terminating gateway. Only `localhost`,
346
+ `127.0.0.1` and `[::1]` may use `http:`. Use `https:`, or point the SDK
347
+ at a loopback address.
348
+ - **Userinfo, query or fragment in the base.** For example
349
+ `https://user:pw@api.hawcx.com`, `https://api.hawcx.com?tenant=x` or
350
+ `https://api.hawcx.com#x`.
351
+ - **BREAKING — the signed JWT `aud` string changes for non-canonical base
352
+ spellings.** Host case and default ports are now normalised, so
353
+ `baseUrl: 'https://API.Hawcx.com:443'` produces
354
+ `aud = https://api.hawcx.com/v1/...`, where 5.2.0 produced
355
+ `https://API.Hawcx.com:443/v1/...`. The new value is the one `fetch`
356
+ actually sends. A server that compares `aud` by exact string against a
357
+ configured non-canonical value will reject the new form. Canonical bases
358
+ (lower-case host, no default port, with or without a trailing slash or a
359
+ path prefix) produce byte-identical URLs and `aud`.
360
+ - Discovered `token_endpoint` and `jwks_uri` values carrying userinfo are now
361
+ rejected (`DiscoveryError`).
362
+
8
363
  ## [5.2.0] - 2026-06-26
9
364
 
10
365
  ### Added — Management API authentication via `private_key_jwt` (Path A)
package/README.md CHANGED
@@ -108,6 +108,7 @@ is the discovered `token_endpoint`, and EdDSA is the only accepted algorithm.
108
108
  | Module | Purpose |
109
109
  |--------|---------|
110
110
  | **OAuth** | Token exchange and JWT verification |
111
+ | **Profile** | Hawcx profile service: users, consents, audit (machine or on behalf of a user) |
111
112
  | **Delegation** | Server-to-server user management (MFA, devices) |
112
113
  | **Step-Up** | Step-up authentication flows |
113
114
 
@@ -130,11 +131,56 @@ const oauth = await HawcxOAuth.fromIssuer({
130
131
  clientId: 'your-client-id', // Required: expected `aud` on id_tokens
131
132
  timeout: 10000, // Optional: per-request timeout in ms
132
133
  discoveryTimeout: 5000, // Optional: discovery fetch timeout in ms
134
+ customDomain: 'https://auth.example.com', // Optional: custom domain origin for browser URLs (a CNAME to Hawcx)
133
135
  });
134
136
 
135
137
  oauth.discoveryMetadata; // resolved OIDC discovery doc, for diagnostics
136
138
  ```
137
139
 
140
+ `customDomain` must be `scheme://host[:port]` only: https (http only for
141
+ loopback), an ASCII/punycode hostname (`xn--...` for IDN) or an IP literal,
142
+ port 1-65535 without leading zeros, no userinfo, path, query or fragment (one
143
+ trailing `/` allowed). IPv6 literals may not embed IPv4 (no `.`). It is
144
+ canonicalised (lower-cased, default port dropped, IPv6 in RFC 5952 form, so
145
+ `http://[0:0::1]` becomes `http://[::1]` and counts as loopback). `null`/`undefined` means unset. An invalid value (including `''`,
146
+ whitespace, `*.example.com` or a trailing dot) throws `HawcxOAuthError`
147
+ (`error: 'invalid_custom_domain'`) from `fromIssuer`, before any network call.
148
+ The SDK only checks the form: `customDomain` must also be the TXT-verified
149
+ domain registered for your client's own tenant, set up as a DNS CNAME to Hawcx
150
+ (Hawcx serves its certificate). Another tenant's verified domain is always refused; when the tenant has
151
+ custom-domain enforcement on, every host other than its own verified domains
152
+ (including IP literals and single-label hosts) is refused too.
153
+
154
+ ##### Custom domain
155
+
156
+ When you serve Hawcx sign-in on your own domain (`auth.example.com`, a DNS
157
+ CNAME to Hawcx), the browser must go to `/authorize` and end-session on that
158
+ domain, while token exchange, JWKS and the `iss` check stay on the Hawcx
159
+ issuer.
160
+ `customDomain` swaps only the scheme + host + port of the two browser URLs;
161
+ everything server-to-server is unchanged.
162
+
163
+ ```typescript
164
+ const oauth = await HawcxOAuth.fromIssuer({
165
+ issuer: 'https://api.hawcx.com',
166
+ configId: process.env.HAWCX_CONFIG_ID!,
167
+ clientId: process.env.HAWCX_CLIENT_ID!,
168
+ customDomain: 'https://auth.example.com',
169
+ });
170
+
171
+ // {issuer}/authorize → https://auth.example.com/authorize?response_type=code&client_id=...
172
+ const loginUrl = oauth.buildAuthorizeUrl({ redirectUri, state, codeChallenge, nonce });
173
+
174
+ // Token exchange still POSTs to https://api.hawcx.com/oauth2/token
175
+ const { claims } = await oauth.exchangeCode(code, codeVerifier, redirectUri, nonce);
176
+
177
+ // {issuer}/oauth2/end_session → https://auth.example.com/oauth2/end_session?client_id=...
178
+ const logoutUrl = oauth.buildEndSessionUrl({ postLogoutRedirectUri, idTokenHint: idToken });
179
+ ```
180
+
181
+ The `nonce` passed to `buildAuthorizeUrl` must be passed again to
182
+ `exchangeCode` (4th argument).
183
+
138
184
  Throws `DiscoveryError` if the discovery document is unreachable, returns
139
185
  non-2xx, isn't valid JSON, or is missing any of `token_endpoint`,
140
186
  `jwks_uri`, `id_token_signing_alg_values_supported`.
@@ -209,6 +255,61 @@ interface JwtClaims {
209
255
  }
210
256
  ```
211
257
 
258
+ #### `buildAuthorizeUrl(options: AuthorizeUrlOptions): string`
259
+
260
+ Build the browser `/authorize` URL (discovery mode only; no network I/O).
261
+ Base is the discovered `authorization_endpoint` (`{issuer}/authorize`), with
262
+ its origin replaced by `customDomain` when set (otherwise the endpoint's scheme
263
+ and authority are kept exactly as discovered). Its path and query are copied
264
+ verbatim; the SDK parameters are appended after them. The URLs are
265
+ semantically, not byte-for-byte, equal to the Python and Java SDKs' (parameter
266
+ encoding of e.g. `~`, `*` and space differs).
267
+
268
+ ```typescript
269
+ const url = oauth.buildAuthorizeUrl({
270
+ redirectUri: 'https://app.example.com/auth/callback', // Required
271
+ state, // Required
272
+ codeChallenge, // Required (S256)
273
+ nonce, // Optional, recommended; pass again to exchangeCode
274
+ scope: 'openid email', // Optional, default 'openid'
275
+ prompt: 'login', // Optional
276
+ extraParams: { login_hint: 'a@example.com' }, // Optional
277
+ });
278
+ ```
279
+
280
+ Always sets `response_type=code`, `client_id` and `code_challenge_method=S256`.
281
+ Blank (empty or ASCII-whitespace-only: space, `\t`, `\n`, `\v`, `\f`, `\r`;
282
+ NBSP is not blank) optional values are omitted; a blank `scope`
283
+ becomes `openid`. `extraParams` keys must be non-empty and values strings, and
284
+ cannot be any parameter the SDK sets, `request`, `request_uri`, or a name
285
+ already in the endpoint's query (names are case-sensitive). Throws
286
+ `HawcxOAuthError` (`error: 'invalid_request'`) for a missing or blank required
287
+ option or a bad `extraParams` entry, `HawcxOAuthError`
288
+ (`error: 'issuer_mode_required'`) in legacy mode, and `DiscoveryError`
289
+ (`error: 'invalid_discovery_document'`) if the discovered
290
+ `authorization_endpoint` is missing, not https, has userinfo, a `#`, any
291
+ character outside printable ASCII or any of `` \ " < > ^ ` { | } ``, or its query
292
+ already sets one of the SDK's parameters, `request` or `request_uri`.
293
+
294
+ #### `buildEndSessionUrl(options?: EndSessionUrlOptions): string`
295
+
296
+ Build the browser logout URL from the discovered `end_session_endpoint`
297
+ (`{issuer}/oauth2/end_session`, same `customDomain` origin swap). Always sends
298
+ `client_id`; blank options are omitted. `buildEndSessionUrl(null)` is the same
299
+ as `buildEndSessionUrl({})`.
300
+
301
+ ```typescript
302
+ const url = oauth.buildEndSessionUrl({
303
+ postLogoutRedirectUri: 'https://app.example.com/', // Optional
304
+ idTokenHint: idToken, // Optional
305
+ state, // Optional
306
+ });
307
+ ```
308
+
309
+ Same errors as `buildAuthorizeUrl` for legacy mode and the discovered
310
+ `end_session_endpoint` (whose query may not set `client_id`,
311
+ `post_logout_redirect_uri`, `id_token_hint` or `state`).
312
+
212
313
  #### `refreshJwks(): void`
213
314
 
214
315
  Reset the JWKS cache so the next verification refetches keys (useful for testing
@@ -251,6 +352,122 @@ try {
251
352
 
252
353
  ---
253
354
 
355
+ ## Profile Module
356
+
357
+ `ProfileClient` calls the Hawcx profile service (`{issuer}/profile/v1/...`).
358
+ Backend only: it is built from a discovery-mode `HawcxOAuth` with a
359
+ `private_key_jwt` signer (see [Confidential clients](#confidential-clients-private_key_jwt)).
360
+
361
+ Client configuration in the Hawcx admin console:
362
+
363
+ - **Machine** (`ProfileClient`): `client_credentials` with
364
+ `allowed_scopes` covering what you call (`users:read`, `users:write`,
365
+ `users:delete`, `consent:read`, `consent:write`, `audit:read`).
366
+ - **On behalf of a user** (`forUser`): `token_exchange_scopes: ["profile:self"]`,
367
+ and the user's ID token must have been issued to this same client.
368
+
369
+ ```typescript
370
+ import { HawcxOAuth, ClientAssertionSigner, ProfileApiError } from '@hawcx/oauth-client';
371
+
372
+ const oauth = (
373
+ await HawcxOAuth.fromIssuer({ issuer, configId, clientId })
374
+ ).withClientAssertion(ClientAssertionSigner.ed25519FromJwk(privateJwk));
375
+
376
+ const profiles = oauth.profile(); // or new ProfileClient(oauth, options)
377
+
378
+ // Machine: any user in the tenant
379
+ const user = await profiles.getUser('user-123');
380
+ await profiles.updateUser('user-123', { display_name: 'Ada', locale: null }); // null clears, omitted = unchanged
381
+ await profiles.setConsent('user-123', 'marketing', { status: 'granted' });
382
+ for await (const p of profiles.iterUsers({ status: 'active' })) console.log(p.user_id);
383
+
384
+ // On behalf of the user, right after sign-in with the ID token you just received
385
+ const me = profiles.forUser(idToken);
386
+ const mine = await me.getMe();
387
+ await me.updateMe({ given_name: 'Ada' });
388
+ ```
389
+
390
+ Machine methods: `getUser`, `listUsers`, `iterUsers`, `createUser`,
391
+ `updateUser`, `replaceUser`, `deleteUser`, `getConsents`, `setConsent`,
392
+ `getAudit`, `iterAudit`. User methods: `getMe`, `updateMe`, `getMyConsents`,
393
+ `setMyConsent`, `getMyAudit`, `iterMyAudit`.
394
+
395
+ `forUser` only works with a fresh ID token: the OP caps the delegated token's
396
+ lifetime at the ID token's `exp`, and ID tokens live 60 s by default
397
+ (`id_token_ttl_seconds`). Use it with the ID token you just received at
398
+ sign-in; for later work on a user's profile, use the machine methods with
399
+ their `user_id` (the ID token's `sub` claim).
400
+
401
+ List methods take
402
+ `{ cursor?, limit? }` and return `{ items, nextCursor }` (consents:
403
+ `{ current, history, nextCursor }`).
404
+
405
+ Options (all optional): `baseUrl` (default `{issuer}/profile`; https, http only
406
+ for loopback), `scopes` (machine; default: none sent, so the OP grants all the
407
+ client's allowed scopes; an empty list throws), `audience` (exchange, default
408
+ `hx-profile`), `timeout` (ms, default 10000; bounds token-endpoint calls as
409
+ well as profile requests), `maxRetries` (default 2), `fetch`.
410
+
411
+ Behaviour: access tokens are cached (expiry counted from when the token
412
+ request was sent) and refreshed before expiry in the background, so callers
413
+ keep the still-valid token meanwhile (one in-flight request shared by
414
+ concurrent callers); a failed early refresh keeps that token and is not
415
+ retried for 30 s (or until expiry); `forUser` tokens are re-exchanged only at
416
+ expiry; a 401 re-mints the token and replays the request once (for `forUser`,
417
+ if the re-exchange fails, e.g. `invalid_grant`, the original 401 is thrown);
418
+ `deleteUser` treats any 2xx as success, whatever the body; GET/PUT/DELETE are retried on network errors, 408, 429, 502-504
419
+ from the profile service (honouring its `Retry-After`, capped at 30 s); a
420
+ token-endpoint network error, 408, 429, 502, 503 or 504 is retried for every
421
+ method, POST/PATCH included, since no profile request was sent, using backoff
422
+ only (the token endpoint's `Retry-After` is ignored); all retries share the
423
+ same `maxRetries` budget; POST/PATCH are otherwise never retried; redirects are
424
+ not followed; a DELETE answered 404 counts as success if any earlier attempt of
425
+ the same call may have run (it got no HTTP response, or got 504), and that
426
+ stays true whatever later attempts answer; a 502 (ambiguous, but a false
427
+ "deleted" is worse than a false 404) or a failure before anything was sent
428
+ does not count: any error with `syscall` `connect`/`getaddrinfo`, or,
429
+ with no `syscall`, refused, DNS, connect timeout or TLS handshake codes
430
+ (unreachable-host/network codes need `syscall: 'connect'`, since they can also
431
+ hit an established keep-alive socket); an error from an injected `fetch` with
432
+ no recognised `code`/`errno` counts as may-have-run;
433
+ iterators stop on a null or empty `next_cursor`, or if the server repeats a
434
+ cursor.
435
+
436
+ Errors: profile-service failures throw `ProfileApiError` (a `HawcxError`) with
437
+ `status` (`0` = no response: network error/timeout; a 2xx whose body was cut
438
+ off mid-read keeps that status, title `Response body read failed`, detail the
439
+ error name, since the call ran), `title`, `detail`, `type`, `instance`,
440
+ `requestId`, `retryAfter`, `missing` (403 insufficient scope), `fields` (422)
441
+ the parsed JSON `body`, and `rawBody` (the body text, e.g. a gateway's HTML
442
+ error page, capped at 4096 characters, never in `message`; on a malformed 2xx
443
+ only when the body is a markup page starting with `<`, since any other body
444
+ may be a mangled profile, i.e. PII); a body
445
+ with no non-blank `title`/`detail` but a string
446
+ `message` (Kong) uses `message` as `detail`, and `requestId` falls back to the
447
+ body's `request_id`; a 2xx body that is not JSON (`Invalid JSON response`) or
448
+ is not a JSON object / lacks a required field, or a 204 to anything but
449
+ `deleteUser` (`Unexpected response body`), is a `ProfileApiError` with the 2xx
450
+ status. Token failures throw
451
+ `TokenExchangeError` (`statusCode`, `error`, `network`: `true` when no
452
+ complete HTTP response came back, i.e. no response at all, `statusCode`
453
+ undefined, or a 2xx whose body was cut off mid-read, `statusCode` set; for
454
+ `exchangeCode` the latter means the code is already spent). Invalid arguments (empty `userId`/`purpose`/`idToken`)
455
+ throw `HawcxError` before any request, as do `userId`/`purpose` values, and
456
+ `createUser`'s `user_id`, that are `.`/`..` or contain `/`, `\` or control
457
+ characters.
458
+
459
+ ```typescript
460
+ try {
461
+ await profiles.createUser({ user_id: 'user-123', given_name: 'Ada', family_name: 'Lovelace' });
462
+ } catch (err) {
463
+ if (err instanceof ProfileApiError && err.status === 409) {
464
+ /* profile already exists */
465
+ } else throw err;
466
+ }
467
+ ```
468
+
469
+ ---
470
+
254
471
  ## Delegation Module
255
472
 
256
473
  Server-to-server API for managing users, MFA, and devices. Requires a Hawcx secret key.
@@ -479,6 +696,36 @@ object, a PKCS#8 PEM string, or a pre-imported jose `KeyLike`.
479
696
  Each request is signed with a short-lived per-request EdDSA JWT whose `aud` is the
480
697
  full request URL and whose `body_sha256` claim covers the exact bytes sent.
481
698
 
699
+ Every successful response is authenticated too. The SDK verifies the
700
+ `X-Response-Signature` JWS (ES256/ES384/ES512) against
701
+ `${issuer}/.well-known/jwks.json`. It also checks that the JWS `iat` is within
702
+ `clockSkewSeconds` (default 300) and that its `body_sha256` matches the exact
703
+ response bytes. `issuer` defaults to `baseUrl`, because the management API and
704
+ the OP share a host in every Hawcx environment. Set `issuer` explicitly if they
705
+ differ, for example in local development.
706
+
707
+ > **Scope:** by default the JWKS comes from the **same origin as the API**.
708
+ > Verification therefore protects against tampering *between* that origin and
709
+ > you: caches, proxies and gateways, or a compromised downstream service. It
710
+ > does **not** protect against a compromised origin. If you need that, pin
711
+ > `issuer` to a separately trusted host.
712
+
713
+ Failures throw one of two subclasses of `DelegationResponseError`, each with a
714
+ stable `code`:
715
+
716
+ - `ResponseSignatureInvalidError` (`code: 'response_signature_invalid'`): the
717
+ header is missing or empty, or the signature does not verify. Treat the
718
+ response as untrusted.
719
+ - `ResponseSignatureUnavailableError` (`code: 'response_signature_unavailable'`):
720
+ the signing keys could not be obtained. The JWKS fetch failed, timed out or
721
+ returned something unusable, or a newly rotated `kid` arrived while refetching
722
+ was cooling down. **The server has already processed the request**, so a
723
+ non-idempotent write such as `consumeReceipt` may have been applied. Reconcile
724
+ state rather than retrying blindly.
725
+
726
+ `verifyResponseSignature: false` turns verification off. It is an emergency
727
+ escape hatch only, and it logs a one-time process warning.
728
+
482
729
  ### Methods
483
730
 
484
731
  #### `stepUp.startToken(options): Promise<StepUpStartTokenResponse>`
@@ -1 +1 @@
1
- {"version":3,"file":"client-assertion.d.ts","sourceRoot":"","sources":["../src/client-assertion.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAmC,KAAK,GAAG,EAAE,MAAM,MAAM,CAAC;AAKjE,qEAAqE;AACrE,eAAO,MAAM,qBAAqB,2DACwB,CAAC;AAK3D,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,iEAAiE;IACjE,OAAO,CAAC,UAAU,CAAC,CAAuB;IAE1C,OAAO;IAUP;;;;;OAKG;IACH,MAAM,CAAC,cAAc,CAAC,UAAU,EAAE,GAAG,GAAG,MAAM,GAAG,qBAAqB;IA0BtE;;;OAGG;IACH,MAAM,CAAC,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,qBAAqB;IAkB3E;;;OAGG;IACH,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,qBAAqB;IAazE;;;;OAIG;IACH,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB;IAK3D,OAAO,CAAC,SAAS;IAYjB;;;;;;OAMG;IACG,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAYhE"}
1
+ {"version":3,"file":"client-assertion.d.ts","sourceRoot":"","sources":["../src/client-assertion.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAmC,KAAK,GAAG,EAAE,MAAM,MAAM,CAAC;AAKjE,qEAAqE;AACrE,eAAO,MAAM,qBAAqB,2DACwB,CAAC;AAK3D,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,iEAAiE;IACjE,OAAO,CAAC,UAAU,CAAC,CAAuB;IAE1C,OAAO;IAUP;;;;;OAKG;IACH,MAAM,CAAC,cAAc,CAAC,UAAU,EAAE,GAAG,GAAG,MAAM,GAAG,qBAAqB;IA0BtE;;;OAGG;IACH,MAAM,CAAC,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,qBAAqB;IAkB3E;;;OAGG;IACH,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,qBAAqB;IAazE;;;;OAIG;IACH,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB;IAK3D,OAAO,CAAC,SAAS;IAYjB;;;;;;OAMG;IACG,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAgBhE"}