@xignet/client 0.0.0-stage → 0.1.0-beta.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.
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/index.cjs +139 -0
- package/dist/index.d.cts +35 -0
- package/package.json +36 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marx Biotech
|
|
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
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xignet/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Signet OAuth **machine client** for Node.js. Uses `openid-client` for the
|
|
4
|
+
`client_credentials` protocol and RFC 6749 `client_secret_basic` encoding,
|
|
5
|
+
including client IDs containing colons. Independent of NestJS, Passport, Medusa
|
|
6
|
+
and the existing `@marxbiotech/signet-integration` resource-server package.
|
|
7
|
+
|
|
8
|
+
Requires Node.js **22.22.0 or newer**. Supports CommonJS `require()` and ESM
|
|
9
|
+
named imports. The CommonJS entry loads the ESM protocol library with native
|
|
10
|
+
dynamic `import()`.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @xignet/client
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { SignetClient, SignetOAuthError } from '@xignet/client';
|
|
18
|
+
|
|
19
|
+
const auth = new SignetClient({
|
|
20
|
+
tokenUrl: process.env.SIGNET_TOKEN_URL!, // token_endpoint from discovery
|
|
21
|
+
clientId: process.env.SIGNET_CLIENT_ID!, // original ID, NOT pre-encoded
|
|
22
|
+
clientSecret: process.env.SIGNET_CLIENT_SECRET!,
|
|
23
|
+
audience: 'https://stocksync.marxbiotech.com/api/mcp',
|
|
24
|
+
scope: 'stocksync:access',
|
|
25
|
+
timeoutMs: 30_000, // optional
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const token = await auth.getAccessToken();
|
|
29
|
+
// Use Authorization: Bearer <token> with your API client; never add X-API-Key.
|
|
30
|
+
// On API 401, invalidate only the rejected token, then retry at most once:
|
|
31
|
+
auth.invalidateToken(token);
|
|
32
|
+
const replacement = await auth.getAccessToken();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Create one client per credentials/audience/scope configuration and reuse it.
|
|
36
|
+
The cache is process-local; it is not persisted. A request reuses the cached
|
|
37
|
+
token until `expires_in` minus a 60-second margin, capped at half the lifetime
|
|
38
|
+
for short-lived tokens. `refreshMarginSeconds` overrides the margin. Time spent
|
|
39
|
+
obtaining the token counts towards its lifetime. Concurrent exchanges share a
|
|
40
|
+
single promise. Failures clear that promise so a later call can recover.
|
|
41
|
+
|
|
42
|
+
`audience` and `scope` are sent in the form body. Credentials use Basic auth;
|
|
43
|
+
the caller must pass their original values without pre-encoding them. The token
|
|
44
|
+
URL must use HTTPS and contain no credentials, query or fragment. Redirects
|
|
45
|
+
are rejected. This package uses the explicitly configured token endpoint; it
|
|
46
|
+
does not perform discovery, decode the access token or manage user sessions.
|
|
47
|
+
|
|
48
|
+
The SDK does **not** retry token failures or make business API requests. API
|
|
49
|
+
401/403 handling belongs to the consumer: invalidate and retry once for 401;
|
|
50
|
+
fail immediately for 403. `invalidateToken(rejectedToken)` cannot evict a newer
|
|
51
|
+
token obtained for another request. There is no refresh token in this flow.
|
|
52
|
+
|
|
53
|
+
`SignetOAuthError` exposes only `code` and optional HTTP `status`. Codes are
|
|
54
|
+
`INVALID_CONFIGURATION`, `TOKEN_REQUEST_FAILED` and `INVALID_TOKEN_RESPONSE`.
|
|
55
|
+
Raw upstream errors, response bodies and causes are discarded so they cannot
|
|
56
|
+
leak credentials or tokens through application logging. The SDK itself emits no
|
|
57
|
+
logs. The optional `fetch` transport override receives credentials; treat it as
|
|
58
|
+
trusted code, preserve abort handling and do not log request headers or bodies.
|
|
59
|
+
|
|
60
|
+
## Migrating from @xignet/oauth-client
|
|
61
|
+
|
|
62
|
+
Replace the package dependency and import source with `@xignet/client`.
|
|
63
|
+
`SignetClient`, `SignetOAuthError`, configuration and runtime behavior are
|
|
64
|
+
unchanged. The previous package remains installable with a deprecation notice.
|
|
65
|
+
|
|
66
|
+
## Development and release
|
|
67
|
+
|
|
68
|
+
From the repository root:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
pnpm install
|
|
72
|
+
pnpm --filter @xignet/client test
|
|
73
|
+
pnpm --filter @xignet/client typecheck
|
|
74
|
+
pnpm --filter @xignet/client build
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Releases are independent of the existing root package. Bump this package's
|
|
78
|
+
version and changelog, then push a matching `client-vX.Y.Z` tag. The
|
|
79
|
+
`client-publish.yml` GitHub Actions workflow validates, builds, checks the
|
|
80
|
+
packed artifact and publishes to npm with OIDC. Configure its npm Trusted
|
|
81
|
+
Publisher as GitHub owner `marxbiobuilder`, repository `signet-integration`,
|
|
82
|
+
workflow `client-publish.yml`. A first package publication/registration
|
|
83
|
+
and npm organization permissions must be established before relying on OIDC.
|
|
84
|
+
Prereleases are published under their first prerelease identifier, not `latest`.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SignetClient = exports.SignetOAuthError = void 0;
|
|
4
|
+
/** Contains no upstream body, URL, credentials, token or raw cause. Safe to log. */
|
|
5
|
+
class SignetOAuthError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
constructor(code, status) {
|
|
9
|
+
super(`Signet OAuth: ${code}${status ? ` (HTTP ${status})` : ''}`);
|
|
10
|
+
this.name = 'SignetOAuthError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.SignetOAuthError = SignetOAuthError;
|
|
16
|
+
/**
|
|
17
|
+
* One instance per client/audience/scope configuration. Holds a single token in
|
|
18
|
+
* process memory. No timers, persistent storage, logging or API retries.
|
|
19
|
+
*/
|
|
20
|
+
class SignetClient {
|
|
21
|
+
#options;
|
|
22
|
+
#cached;
|
|
23
|
+
#inFlight;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
const invalid = () => new SignetOAuthError('INVALID_CONFIGURATION');
|
|
26
|
+
for (const value of [
|
|
27
|
+
options.tokenUrl,
|
|
28
|
+
options.clientId,
|
|
29
|
+
options.clientSecret,
|
|
30
|
+
options.audience,
|
|
31
|
+
options.scope,
|
|
32
|
+
]) {
|
|
33
|
+
if (typeof value !== 'string' || !value.trim())
|
|
34
|
+
throw invalid();
|
|
35
|
+
}
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(options.tokenUrl);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw invalid();
|
|
42
|
+
}
|
|
43
|
+
if (url.protocol !== 'https:' ||
|
|
44
|
+
url.username ||
|
|
45
|
+
url.password ||
|
|
46
|
+
url.search ||
|
|
47
|
+
url.hash)
|
|
48
|
+
throw invalid();
|
|
49
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
50
|
+
const margin = options.refreshMarginSeconds ?? 60;
|
|
51
|
+
if (!Number.isSafeInteger(timeout) ||
|
|
52
|
+
timeout <= 0 ||
|
|
53
|
+
timeout > 2_147_483_647 ||
|
|
54
|
+
!Number.isFinite(margin) ||
|
|
55
|
+
margin < 0 ||
|
|
56
|
+
(options.fetch !== undefined && typeof options.fetch !== 'function'))
|
|
57
|
+
throw invalid();
|
|
58
|
+
this.#options = Object.freeze({
|
|
59
|
+
...options,
|
|
60
|
+
timeoutMs: timeout,
|
|
61
|
+
refreshMarginSeconds: margin,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async getAccessToken() {
|
|
65
|
+
if (this.#cached && Date.now() < this.#cached.expiresAt)
|
|
66
|
+
return this.#cached.value;
|
|
67
|
+
if (this.#inFlight)
|
|
68
|
+
return this.#inFlight;
|
|
69
|
+
// A finally on the shared promise avoids rejected promises being retained.
|
|
70
|
+
this.#inFlight = this.#requestToken().finally(() => {
|
|
71
|
+
this.#inFlight = undefined;
|
|
72
|
+
});
|
|
73
|
+
return this.#inFlight;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Invalidate only the rejected token. A late 401 must not evict a newer token
|
|
77
|
+
* already obtained for another request. Does not cancel an in-flight exchange.
|
|
78
|
+
*/
|
|
79
|
+
invalidateToken(rejectedToken) {
|
|
80
|
+
if (this.#cached?.value === rejectedToken)
|
|
81
|
+
this.#cached = undefined;
|
|
82
|
+
}
|
|
83
|
+
async #requestToken() {
|
|
84
|
+
let status;
|
|
85
|
+
try {
|
|
86
|
+
// Keep ESM import native in the CommonJS build for Node.js >=22.22.
|
|
87
|
+
const oidc = await import('openid-client');
|
|
88
|
+
const options = this.#options;
|
|
89
|
+
const config = new oidc.Configuration({
|
|
90
|
+
issuer: new URL(options.tokenUrl).origin,
|
|
91
|
+
token_endpoint: options.tokenUrl,
|
|
92
|
+
}, options.clientId, { token_endpoint_auth_method: 'client_secret_basic' }, oidc.ClientSecretBasic(options.clientSecret));
|
|
93
|
+
// The protocol library uses redirect:error. Preserve it to avoid forwarding
|
|
94
|
+
// credentials to redirected endpoints; share a deadline across transport.
|
|
95
|
+
const signal = AbortSignal.timeout(options.timeoutMs);
|
|
96
|
+
const transport = options.fetch ?? globalThis.fetch;
|
|
97
|
+
config[oidc.customFetch] = async (url, init) => {
|
|
98
|
+
// This SDK only performs form-encoded client credentials requests.
|
|
99
|
+
if (!(init.body instanceof URLSearchParams)) {
|
|
100
|
+
throw new SignetOAuthError('TOKEN_REQUEST_FAILED');
|
|
101
|
+
}
|
|
102
|
+
const response = await transport(url, {
|
|
103
|
+
...init,
|
|
104
|
+
body: init.body,
|
|
105
|
+
redirect: 'error',
|
|
106
|
+
signal,
|
|
107
|
+
});
|
|
108
|
+
status = response.status;
|
|
109
|
+
return response;
|
|
110
|
+
};
|
|
111
|
+
const requestedAt = Date.now();
|
|
112
|
+
const data = await oidc.clientCredentialsGrant(config, {
|
|
113
|
+
audience: options.audience,
|
|
114
|
+
scope: options.scope,
|
|
115
|
+
});
|
|
116
|
+
if (!data.access_token ||
|
|
117
|
+
/\s/.test(data.access_token) ||
|
|
118
|
+
data.token_type.toLowerCase() !== 'bearer' ||
|
|
119
|
+
typeof data.expires_in !== 'number' ||
|
|
120
|
+
!Number.isFinite(data.expires_in) ||
|
|
121
|
+
data.expires_in <= 0)
|
|
122
|
+
throw new SignetOAuthError('INVALID_TOKEN_RESPONSE');
|
|
123
|
+
const margin = Math.min(options.refreshMarginSeconds, data.expires_in / 2);
|
|
124
|
+
const expiresAt = requestedAt + (data.expires_in - margin) * 1000;
|
|
125
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
|
126
|
+
throw new SignetOAuthError('INVALID_TOKEN_RESPONSE');
|
|
127
|
+
}
|
|
128
|
+
this.#cached = { value: data.access_token, expiresAt };
|
|
129
|
+
return data.access_token;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof SignetOAuthError)
|
|
133
|
+
throw error;
|
|
134
|
+
// Discard openid-client errors: cause/response/error_description may be sensitive.
|
|
135
|
+
throw new SignetOAuthError(status === 200 ? 'INVALID_TOKEN_RESPONSE' : 'TOKEN_REQUEST_FAILED', status);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
exports.SignetClient = SignetClient;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface SignetClientOptions {
|
|
2
|
+
/** Explicit token_endpoint from the issuer's discovery document. HTTPS only. */
|
|
3
|
+
tokenUrl: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
/** Signet/Hydra expects audience in the form body, not resource or query. */
|
|
7
|
+
audience: string;
|
|
8
|
+
/** Required: use the scopes configured for this resource/client. */
|
|
9
|
+
scope: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
refreshMarginSeconds?: number;
|
|
12
|
+
/** Optional transport override for proxy integration and isolated tests. */
|
|
13
|
+
fetch?: typeof globalThis.fetch;
|
|
14
|
+
}
|
|
15
|
+
export type SignetOAuthErrorCode = 'INVALID_CONFIGURATION' | 'TOKEN_REQUEST_FAILED' | 'INVALID_TOKEN_RESPONSE';
|
|
16
|
+
/** Contains no upstream body, URL, credentials, token or raw cause. Safe to log. */
|
|
17
|
+
export declare class SignetOAuthError extends Error {
|
|
18
|
+
readonly code: SignetOAuthErrorCode;
|
|
19
|
+
readonly status?: number;
|
|
20
|
+
constructor(code: SignetOAuthErrorCode, status?: number);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One instance per client/audience/scope configuration. Holds a single token in
|
|
24
|
+
* process memory. No timers, persistent storage, logging or API retries.
|
|
25
|
+
*/
|
|
26
|
+
export declare class SignetClient {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(options: SignetClientOptions);
|
|
29
|
+
getAccessToken(): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Invalidate only the rejected token. A late 401 must not evict a newer token
|
|
32
|
+
* already obtained for another request. Does not cancel an in-flight exchange.
|
|
33
|
+
*/
|
|
34
|
+
invalidateToken(rejectedToken: string): void;
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xignet/client",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Signet machine-to-machine OAuth client with safe errors, token caching and client_secret_basic authentication.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/marxbiobuilder/signet-integration.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"type": "commonjs",
|
|
12
|
+
"main": "./dist/index.cjs",
|
|
13
|
+
"types": "./dist/index.d.cts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.22.0"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"registry": "https://registry.npmjs.org"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"openid-client": "^6.8.9"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.10.7",
|
|
36
|
+
"typescript": "^5.7.3"
|
|
37
|
+
}
|
|
38
|
+
}
|