requestshield 2.0.0 → 2.1.0-qat.425

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 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; your browser stays signed in.
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
 
@@ -0,0 +1,7 @@
1
+ # Public QAT settings for the QAT npm release and source runner. Never add secrets.
2
+ API_URL=https://management-api-qat.intellifend.ai
3
+ CHALLENGE_URL=https://challenge-dev.intellifend.ai
4
+ DOCS_URL=https://docs-qat.intellifend.ai
5
+ OAUTH_ISSUER=https://auth-qat.intellifend.ai
6
+ OAUTH_CLIENT_ID=requestshield-cli
7
+ OAUTH_RESOURCE=urn:theair:management-api:dev
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "requestshield",
3
- "version": "2.0.0",
3
+ "version": "2.1.0-qat.425",
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": "latest"
12
+ "tag": "qat"
13
13
  },
14
14
  "files": [
15
15
  "src/",
16
- "config/.env.prod",
16
+ "config/.env.qat",
17
17
  "skills/",
18
18
  "README.md"
19
19
  ],
@@ -22,6 +22,6 @@
22
22
  },
23
23
  "license": "UNLICENSED",
24
24
  "requestshield": {
25
- "profile": "prod"
25
+ "profile": "qat"
26
26
  }
27
27
  }
@@ -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, including non-URL Stytch project IDs.
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
- return {issuer, clientId, apiUrl: validatedUrl(apiUrl, "API_URL")};
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.
@@ -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
  }
@@ -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 {{issuer: string, clientId: string, apiUrl: string}} OAuthConfig */
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; never revoke provider tokens. */
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
- return { profile: this.profile, removed: await this.files.remove() };
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
- return { version: 1, ...this.#config(), ...credentials, refreshState: "ready" };
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