requestshield 2.0.0 → 2.1.0-stg.427
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/README.md +2 -1
- package/config/.env.stg +7 -0
- package/package.json +4 -4
- package/src/commands/signin.mjs +1 -0
- package/src/config.mjs +11 -7
- package/src/oauth-client.mjs +40 -4
- package/src/session-store.mjs +17 -5
- package/config/.env.prod +0 -9
package/README.md
CHANGED
|
@@ -87,7 +87,8 @@ Status is a local check and does not contact RequestShield. JSON includes `state
|
|
|
87
87
|
and `localOnly: true`, plus `expiresAt` and `scopes` when applicable. States are
|
|
88
88
|
`signed_out`, `valid`, `expired`, `refresh_uncertain`, `config_mismatch`, `invalid`
|
|
89
89
|
and `configuration_error`. Local `valid` status does not confirm online access.
|
|
90
|
-
Logout removes your saved CLI session
|
|
90
|
+
Logout removes your saved CLI session and revokes it at the auth service (best
|
|
91
|
+
effort); your browser stays signed in.
|
|
91
92
|
|
|
92
93
|
## Applications and secrets
|
|
93
94
|
|
package/config/.env.stg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Public staging settings for the STG npm release and source runner. Never add secrets.
|
|
2
|
+
API_URL=https://management-api-stg.intellifend.ai
|
|
3
|
+
CHALLENGE_URL=https://challenge-stg.intellifend.ai
|
|
4
|
+
DOCS_URL=https://docs-stg.intellifend.ai
|
|
5
|
+
OAUTH_ISSUER=https://auth-stg.intellifend.ai
|
|
6
|
+
OAUTH_CLIENT_ID=requestshield-cli
|
|
7
|
+
OAUTH_RESOURCE=urn:theair:management-api:stg
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "requestshield",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.1.0-stg.427",
|
|
4
4
|
"description": "Customer CLI for IntelliFend RequestShield.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public",
|
|
11
11
|
"registry": "https://registry.npmjs.org/",
|
|
12
|
-
"tag": "
|
|
12
|
+
"tag": "stg"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src/",
|
|
16
|
-
"config/.env.
|
|
16
|
+
"config/.env.stg",
|
|
17
17
|
"skills/",
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
@@ -22,6 +22,6 @@
|
|
|
22
22
|
},
|
|
23
23
|
"license": "UNLICENSED",
|
|
24
24
|
"requestshield": {
|
|
25
|
-
"profile": "
|
|
25
|
+
"profile": "stg"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/src/commands/signin.mjs
CHANGED
|
@@ -57,6 +57,7 @@ export async function signin(options, deps) {
|
|
|
57
57
|
response_type: "code", redirect_uri: receiver.redirectUri,
|
|
58
58
|
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
59
59
|
code_challenge_method: "S256", state, scope: REQUESTED_OAUTH_SCOPES.join(" "),
|
|
60
|
+
resource: deps.oauth.config.resource,
|
|
60
61
|
}).toString();
|
|
61
62
|
if (noOpen) {
|
|
62
63
|
showAuthorizationUrl(authorizeUrl.href);
|
package/src/config.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { getPackageProfile } from "./package-profile.mjs";
|
|
|
8
8
|
|
|
9
9
|
/** @typedef {'qat' | 'stg' | 'prod'} Profile */
|
|
10
10
|
const PROFILES = new Set(["qat", "stg", "prod"]);
|
|
11
|
-
const CONFIG_KEYS = new Set(["API_URL", "CHALLENGE_URL", "DOCS_URL", "OAUTH_ISSUER", "OAUTH_CLIENT_ID", "OAUTH_AUTHORIZATION_ISSUER"]);
|
|
11
|
+
const CONFIG_KEYS = new Set(["API_URL", "CHALLENGE_URL", "DOCS_URL", "OAUTH_ISSUER", "OAUTH_CLIENT_ID", "OAUTH_AUTHORIZATION_ISSUER", "OAUTH_RESOURCE"]);
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_MAVEN_REPOSITORY =
|
|
14
14
|
"https://sdk.intellifend.com/packages/maven";
|
|
@@ -24,17 +24,17 @@ export const REQUESTED_OAUTH_SCOPES = Object.freeze([
|
|
|
24
24
|
"theair:secrets:write",
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
|
-
/** @typedef {{issuer: string, clientId: string, apiUrl: string}} OAuthConfig */
|
|
27
|
+
/** @typedef {{issuer: string, clientId: string, apiUrl: string, resource: string}} OAuthConfig */
|
|
28
28
|
|
|
29
29
|
/** Read only the selected package file; never load settings into process.env.
|
|
30
30
|
* @param {Profile} [profile] @returns {OAuthConfig}
|
|
31
31
|
*/
|
|
32
32
|
export function getOAuthConfig(profile = "prod") {
|
|
33
33
|
const values = readProfileConfig(profile);
|
|
34
|
-
if (!values.OAUTH_ISSUER || !values.OAUTH_CLIENT_ID) {
|
|
34
|
+
if (!values.OAUTH_ISSUER || !values.OAUTH_CLIENT_ID || !values.OAUTH_RESOURCE) {
|
|
35
35
|
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
36
36
|
}
|
|
37
|
-
return validateOAuthConfig({issuer: values.OAUTH_ISSUER, clientId: values.OAUTH_CLIENT_ID, apiUrl: configuredApiUrl(values, profile)});
|
|
37
|
+
return validateOAuthConfig({issuer: values.OAUTH_ISSUER, clientId: values.OAUTH_CLIENT_ID, apiUrl: configuredApiUrl(values, profile), resource: values.OAUTH_RESOURCE});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Callback-only issuer pin; it never selects discovery or token endpoints.
|
|
@@ -45,7 +45,7 @@ export function getAuthorizationIssuer(profile, fallbackIssuer) {
|
|
|
45
45
|
return value ? validateAuthorizationIssuer(value) : fallbackIssuer;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** Preserve exact provider identifiers
|
|
48
|
+
/** Preserve exact provider identifiers; the callback `iss` may be an opaque identifier.
|
|
49
49
|
* @param {unknown} value @returns {string}
|
|
50
50
|
*/
|
|
51
51
|
export function validateAuthorizationIssuer(value) {
|
|
@@ -58,13 +58,17 @@ export function validateAuthorizationIssuer(value) {
|
|
|
58
58
|
/** Validate injected OAuth clients as well as file-based configuration.
|
|
59
59
|
* @param {OAuthConfig} config @returns {OAuthConfig}
|
|
60
60
|
*/
|
|
61
|
-
export function validateOAuthConfig({issuer: issuerValue, clientId, apiUrl}) {
|
|
61
|
+
export function validateOAuthConfig({issuer: issuerValue, clientId, apiUrl, resource}) {
|
|
62
62
|
const issuer = validatedUrl(issuerValue, "OAUTH_ISSUER");
|
|
63
63
|
if (new URL(issuer).protocol !== "https:") throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
64
64
|
if (typeof clientId !== "string" || !/^[A-Za-z0-9._~-]{1,256}$/.test(clientId)) {
|
|
65
65
|
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
// Management audience sent on authorize, code exchange and refresh so tokens are audience-bound JWTs.
|
|
68
|
+
if (typeof resource !== "string" || resource.length > 256 || !/^urn:[A-Za-z0-9][A-Za-z0-9-]{1,31}:[A-Za-z0-9()+,.:=@;$_!*'%/~-]+$/.test(resource)) {
|
|
69
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
70
|
+
}
|
|
71
|
+
return {issuer, clientId, apiUrl: validatedUrl(apiUrl, "API_URL"), resource};
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
/** Shared API selection for signin binding and every authenticated command.
|
package/src/oauth-client.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { CliError } from "./errors.mjs";
|
|
|
3
3
|
import { validateOAuthConfig, REQUESTED_OAUTH_SCOPES, validateBearerToken, validatedUrl } from "./config.mjs";
|
|
4
4
|
|
|
5
5
|
/** @typedef {{accessToken: string, refreshToken: string, expiresAt: number, scopes: string[]}} OAuthCredentials */
|
|
6
|
-
/** @typedef {{authorizationEndpoint: string, tokenEndpoint: string}} OAuthDiscovery */
|
|
6
|
+
/** @typedef {{authorizationEndpoint: string, tokenEndpoint: string, revocationEndpoint?: string}} OAuthDiscovery */
|
|
7
7
|
|
|
8
8
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
|
+
const REVOKE_TIMEOUT_MS = 5_000;
|
|
9
10
|
|
|
10
11
|
export class OAuthClient {
|
|
11
12
|
/** @param {{config: import('./config.mjs').OAuthConfig, fetchImpl?: typeof fetch, now?: () => number, timeoutMs?: number}} options */
|
|
@@ -33,7 +34,7 @@ export class OAuthClient {
|
|
|
33
34
|
if (tokenUrl.protocol !== "https:" || tokenUrl.origin !== new URL(this.config.issuer).origin) throw invalidResponse();
|
|
34
35
|
if (!Array.isArray(body.response_types_supported) || !body.response_types_supported.includes("code")
|
|
35
36
|
|| !Array.isArray(body.code_challenge_methods_supported) || !body.code_challenge_methods_supported.includes("S256")) throw invalidResponse();
|
|
36
|
-
this.discovery = {authorizationEndpoint, tokenEndpoint};
|
|
37
|
+
this.discovery = {authorizationEndpoint, tokenEndpoint, revocationEndpoint: sameOriginEndpoint(body.revocation_endpoint, this.config.issuer)};
|
|
37
38
|
return this.discovery;
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -42,7 +43,7 @@ export class OAuthClient {
|
|
|
42
43
|
const {tokenEndpoint} = await this.discover({signal});
|
|
43
44
|
const {body} = await this.#request(tokenEndpoint, {
|
|
44
45
|
method: "POST",
|
|
45
|
-
body: new URLSearchParams({grant_type: "authorization_code", client_id: this.config.clientId, code, code_verifier: verifier, redirect_uri: redirectUri}),
|
|
46
|
+
body: new URLSearchParams({grant_type: "authorization_code", client_id: this.config.clientId, code, code_verifier: verifier, redirect_uri: redirectUri, resource: this.config.resource}),
|
|
46
47
|
}, signal);
|
|
47
48
|
return this.#credentials(body);
|
|
48
49
|
}
|
|
@@ -53,7 +54,7 @@ export class OAuthClient {
|
|
|
53
54
|
try {
|
|
54
55
|
const {body} = await this.#request(tokenEndpoint, {
|
|
55
56
|
method: "POST",
|
|
56
|
-
body: new URLSearchParams({grant_type: "refresh_token", client_id: this.config.clientId, refresh_token: refreshToken}),
|
|
57
|
+
body: new URLSearchParams({grant_type: "refresh_token", client_id: this.config.clientId, refresh_token: refreshToken, resource: this.config.resource}),
|
|
57
58
|
});
|
|
58
59
|
return this.#credentials(body);
|
|
59
60
|
} catch (error) {
|
|
@@ -62,6 +63,29 @@ export class OAuthClient {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/** Best effort (RFC 7009): bounded, never throws, never echoes the response.
|
|
67
|
+
* @param {string} refreshToken @returns {Promise<boolean>} true when the provider accepted the request
|
|
68
|
+
*/
|
|
69
|
+
async revoke(refreshToken) {
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timeout = setTimeout(() => controller.abort(), Math.min(this.timeoutMs, REVOKE_TIMEOUT_MS));
|
|
72
|
+
try {
|
|
73
|
+
const {revocationEndpoint} = await this.discover({signal: controller.signal});
|
|
74
|
+
if (!revocationEndpoint) return false;
|
|
75
|
+
const response = await Promise.race([
|
|
76
|
+
this.fetchImpl(revocationEndpoint, {
|
|
77
|
+
method: "POST", signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
|
|
78
|
+
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
|
79
|
+
body: new URLSearchParams({token: refreshToken, token_type_hint: "refresh_token", client_id: this.config.clientId}),
|
|
80
|
+
}),
|
|
81
|
+
new Promise((_, reject) => controller.signal.addEventListener("abort", reject, {once: true})),
|
|
82
|
+
]);
|
|
83
|
+
void response.body?.cancel().catch(() => {});
|
|
84
|
+
return response.ok;
|
|
85
|
+
} catch { return false; }
|
|
86
|
+
finally { clearTimeout(timeout); controller.abort(); }
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
/** @param {Record<string, unknown>} body @returns {OAuthCredentials} */
|
|
66
90
|
#credentials(body) {
|
|
67
91
|
let accessToken;
|
|
@@ -148,6 +172,18 @@ export class OAuthClient {
|
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
|
|
175
|
+
/** Optional endpoints are used only when HTTPS on the issuer origin; otherwise ignored.
|
|
176
|
+
* @param {unknown} value @param {string} issuer @returns {string | undefined}
|
|
177
|
+
*/
|
|
178
|
+
function sameOriginEndpoint(value, issuer) {
|
|
179
|
+
if (value === undefined) return undefined;
|
|
180
|
+
try {
|
|
181
|
+
const endpoint = validatedUrl(value, "revocation endpoint");
|
|
182
|
+
const url = new URL(endpoint);
|
|
183
|
+
return url.protocol === "https:" && url.origin === new URL(issuer).origin ? endpoint : undefined;
|
|
184
|
+
} catch { return undefined; }
|
|
185
|
+
}
|
|
186
|
+
|
|
151
187
|
function invalidResponse() {
|
|
152
188
|
return new CliError("Sign-in could not be completed. Try again; if the problem continues, contact support.", {code: "OAUTH_INVALID_RESPONSE", exitCode: 3});
|
|
153
189
|
}
|
package/src/session-store.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { OAuthClient } from "./oauth-client.mjs";
|
|
|
8
8
|
import { SessionFiles } from "./session-files.mjs";
|
|
9
9
|
|
|
10
10
|
/** @typedef {{accessToken: string, refreshToken: string, expiresAt: number, scopes: string[]}} Credentials */
|
|
11
|
-
/** @typedef {
|
|
11
|
+
/** @typedef {import('./config.mjs').OAuthConfig} OAuthConfig */
|
|
12
12
|
/** @typedef {Credentials & {version: 1, issuer: string, clientId: string, apiUrl: string, refreshState: "ready" | "in_progress"}} SavedSession */
|
|
13
13
|
/** @typedef {'signed_out' | 'valid' | 'expired' | 'refresh_uncertain' | 'config_mismatch' | 'invalid' | 'configuration_error'} SessionState */
|
|
14
14
|
/** @typedef {{profile: import('./config.mjs').Profile, apiUrl: string | null, issuer: string | null, clientId: string | null, state: SessionState, localOnly: true, expiresAt?: number, scopes?: string[]}} SessionStatus */
|
|
@@ -18,7 +18,7 @@ const REFRESH_WINDOW_MS = 60_000;
|
|
|
18
18
|
export class SessionStore {
|
|
19
19
|
/**
|
|
20
20
|
* @param {{env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDir?: string, profile?: import("./config.mjs").Profile,
|
|
21
|
-
* config?: OAuthConfig, oauth?: {refresh(token: string): Promise<Credentials>},
|
|
21
|
+
* config?: OAuthConfig, oauth?: {refresh(token: string): Promise<Credentials>, revoke?(token: string): Promise<boolean>},
|
|
22
22
|
* now?: () => number, fs?: Partial<typeof import("node:fs/promises")>,
|
|
23
23
|
* protectPath?: (file: string, directory: boolean) => Promise<void>, lockWaitMs?: number}} [options]
|
|
24
24
|
*/
|
|
@@ -78,9 +78,20 @@ export class SessionStore {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** Remove only this profile's local credential
|
|
81
|
+
/** Remove only this profile's local credential, then revoke its refresh token
|
|
82
|
+
* best effort. Revocation is bounded and never fails logout. */
|
|
82
83
|
async signout() {
|
|
83
|
-
|
|
84
|
+
let refreshToken, config;
|
|
85
|
+
try {
|
|
86
|
+
config = this.#config();
|
|
87
|
+
const session = checkedSession(await this.files.read());
|
|
88
|
+
if (matchesConfig(session, config)) refreshToken = session.refreshToken;
|
|
89
|
+
} catch { /* Unreadable, mismatched or unconfigured sessions are still removed locally. */ }
|
|
90
|
+
const removed = await this.files.remove();
|
|
91
|
+
if (refreshToken && config) {
|
|
92
|
+
try { await (this.oauth ?? new OAuthClient({ config })).revoke?.(refreshToken); } catch { /* Best effort. */ }
|
|
93
|
+
}
|
|
94
|
+
return { profile: this.profile, removed };
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
async loadToken() {
|
|
@@ -127,7 +138,8 @@ export class SessionStore {
|
|
|
127
138
|
#record(value) {
|
|
128
139
|
const credentials = checkedCredentials(value);
|
|
129
140
|
if (credentials.expiresAt <= this.now()) throw invalidSession();
|
|
130
|
-
|
|
141
|
+
const { issuer, clientId, apiUrl } = this.#config();
|
|
142
|
+
return { version: 1, issuer, clientId, apiUrl, ...credentials, refreshState: "ready" };
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
/** @returns {Promise<SavedSession>} */
|
package/config/.env.prod
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# Public production settings shipped in the npm package. Never add secrets.
|
|
2
|
-
API_URL=https://management-api.intellifend.ai
|
|
3
|
-
CHALLENGE_URL=https://challenge.intellifend.ai
|
|
4
|
-
DOCS_URL=https://docs.intellifend.ai
|
|
5
|
-
# Fill in the production Connected App values before releasing sign-in.
|
|
6
|
-
OAUTH_ISSUER=https://diligent-snagglefoot-6840.customers.stytch.com
|
|
7
|
-
# Optional callback iss pin; blank uses OAUTH_ISSUER.
|
|
8
|
-
OAUTH_AUTHORIZATION_ISSUER=
|
|
9
|
-
OAUTH_CLIENT_ID=connected-app-live-8383c2b5-6f1e-42b5-af4e-6c49e5bb5fe6
|